Skip to content

Add experimental Factory Droid (droid) host - #159

Open
thezelium wants to merge 10 commits into
microsoft:mainfrom
thezelium:add-factory-droid-host
Open

thezelium wants to merge 10 commits into
microsoft:mainfrom
thezelium:add-factory-droid-host

Conversation

@thezelium

@thezelium thezelium commented Nov 6, 2026 •

Copy link
Copy Markdown

An experimental adapter for Factory Droid 0.233.0 on macOS, continuing #143 and related to
#85. Rebased onto main at 0aa2aca, so it carries the Grok host (#148): Grok and Droid sit side
by side in every host list, and Grok is detected first.

What this adds

  • tokeneconomizer/trace/droid.py reads a Droid session log: one JSONL per session under
    ~/.factory/sessions/<encoded workspace>/, a session_start header carrying the cwd, and
    Claude-shaped message records. It maps Droid's tool names (Execute/Script to bash,
    Create/Write to create, Edit/ApplyPatch to edit), counts prompts (Droid's user_only
    and llm_only messages and injected reminders are not prompts), and prices the session from
    the tokenUsage Droid writes in <session id>.settings.json beside the log. Only
    tokenUsage is read: inclusiveTokenUsage also counts child sessions, which are folded on
    their own.
  • tokeneconomizer/plugin/hosts.py grows a droid host on the Claude-shaped hook contract
    (session_id, transcript_path, cwd, hook_event_name), and defaults on the Host base
    for the session-log details the history import reads, so history.py no longer hard-codes
    Claude Code's functions.
  • te --host droid history import learns from earlier Droid sessions. It makes no model call
    and runs only into a repository with nothing folded yet.
  • plugin/.factory-plugin/plugin.json, plugin/hooks/droid-hooks.json and
    scripts/build_plugin.py --droid make the installable bundle: SessionStart,
    UserPromptSubmit, PostToolUse, Stop, SessionEnd, and no PostToolUseFailure, which
    Droid does not document. Micro-skills arrive as hookSpecificOutput.additionalContext; the
    loop guard's still-stuck notice travels in the model's reply, because Droid documents no
    systemMessage.
  • droid_sidecall, off by default: the plugin's own calls run droid exec, which writes a
    session log of its own, so they stay off unless config set droid_sidecall=on or
    TE_DROID_SIDECALL=1. When on, the call starts through program() like every other side
    call and passes --only-tools Read, so Read is the only tool it may use; Droid has no
    setting for a call with no tool at all. With them off, microskills=envfacts still learns and
    the loop guard keeps its rule-based backstop.
  • docs/DROID.md, and the README, plugin README, CLAUDE.md, docs/PLUGIN.md,
    trace/README.md and hook.py entries -- all marked experimental, with the version and
    platform that were tested.

Verification

  • python -m pytest tests experiments/tests: 1225 tests, 1205 passed, 19 skipped, 1 failed.
    The one failure is
    experiments/tests/test_tooling.py::test_harness_emits_the_goals_toggle_and_the_cli_accepts_the_whole_mask,
    which fails the same way on unmodified main (0aa2aca) on this machine: its 90 s
    codex --strict-config probe times out against the locally installed codex. It is not from
    this change. On main the suite is 1202 tests with that same single failure; the 23 added
    tests are this adapter's.
  • python -m pytest tests (what CI runs): 828 tests, 812 passed, 16 skipped, 0 failed, on
    Python 3.11 and 3.13.
  • Live, 2026-11-05, Droid 0.233.0, macOS: a new session received the 793-character block as
    hookSpecificOutput.additionalContext, and the model repeated it verbatim.
  • Live, 2026-11-07, Droid 0.233.0, macOS: with the side call's own flags,
    droid exec --list-tools reports Read as the only tool allowed, MCP tools included (without
    --only-tools, the default read-only autonomy also allows Execute, the web tools and every
    MCP tool). A real side call asked to run shell commands, write a file and search the web
    called no tool (its session log holds no tool_use) and wrote nothing.

Limits

  • Experimental: tested on 0.233.0 only, and on macOS only. On Windows only the offline test
    suite has run, in CI.
  • Side calls stay off by default: Droid cannot run droid exec with no tool at all
    (--only-tools "" means no filter), and the call writes a session log of its own.
  • Tool pruning is not available: Droid's TUI has no persistent per-tool setting (Factory issue
    Protect canonical index when aggregation output is explicit #65), and droid exec --only-tools is exec-only. The controller reports it as unavailable
    rather than writing a deny list.

AI disclosure: the adapter and its tests were produced with Factory Droid (an AI coding agent) at
my direction.

Closes #143
Related to #85

@thezelium

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@SakshamUboweja

Copy link
Copy Markdown
Collaborator

Thanks, this looks good overall. Two things on the side call before we merge:

Start droid the same way the other side calls start their agent. That keeps the prompt safe on Windows.
Turn the side call's tools off with a real setting, not just an instruction in the prompt. The other agents already do this. If Droid has no setting for it, leaving side calls off by default is fine, but say so in the Droid docs.
After that, please rebase on main. #147 and #148 touch the same lists.

thezelium and others added 10 commits November 7, 2026 12:44
Factory Droid documents the same hook contract this plugin already speaks -- session_id,
transcript_path, cwd, hook_event_name, and SessionStart's hookSpecificOutput.additionalContext
-- so it needs an adapter, not a second design.

- trace/droid.py reads a Droid session: one JSONL file per session under
  ~/.factory/sessions/<encoded workspace>/, a session_start header carrying the cwd, and
  message records shaped like a Claude Code transcript. Tool names are mapped (Execute and
  Script to Bash, Create and Write to Write, Edit and ApplyPatch to Edit). `started` is epoch
  seconds, as on every other host.
- A prompt is a user message that is neither an injected reminder nor a tool result, and not
  one Droid hides (`visibility: user_only` or `llm_only`).
- Session cost: Droid's transcript carries no usage, but `<session id>.settings.json` beside
  it carries `tokenUsage`. Only that figure is read -- `inclusiveTokenUsage` also counts child
  sessions, which are folded on their own.
- plugin/hosts.py grows a Host base with defaults for the reader methods the mechanisms call,
  and a Droid host on top; history.py uses those methods instead of Claude Code's functions, so
  the history import, the envelope replay and the folder rule work for either.
- Side calls run through `droid exec`, which writes a session log of its own; they stay off
  unless TE_DROID_SIDECALL=1. With them off, `microskills=envfacts` still learns.
- Tool pruning reports itself unavailable: the TUI has no persistent per-tool setting
  (Factory issue microsoft#65), and `droid exec --only-tools` has no interactive equivalent.
- scripts/build_plugin.py --droid writes a native bundle: .factory-plugin/plugin.json,
  hooks/hooks.json in Droid's top-level shape, commands/ named tokeneconomizer-*, and the
  package copied into lib/ (Droid copies the plugin into its cache, so a symlink would break).
- Tests: tests/test_droid.py covers the reader, the mapping, usage and fallback pricing, the
  history import, the hidden-message rule, the hook's own output including an injected fact,
  and the bundle; test_live_hooks.py exercises the guard on Droid payloads.

Not verified end to end: a learned fact reaching a live Droid model. The hook writes the right
JSON; Droid appending it to the model's context is documented, not observed here.

Refs: microsoft#85
The Factory desktop app gives its sessions no way to set an environment variable, so
`TE_DROID_SIDECALL=1` could only be used from a terminal. `droid_sidecall` says the same thing in
the config file, which both surfaces read, and stays off by default: `droid exec` writes a
session log of its own.
A new Droid 0.233.0 session received the 793-character block as
`hookSpecificOutput.additionalContext` and the model repeated it verbatim.
Update the four places that still called this hop unverified.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The row said only `0.233.0`, while the Cursor row names the platforms it was tested on.
Support was verified on 0.233.0 on macOS only, so the table now says so and points at the
notes below, the way the experimental hosts beside it do.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
docs/DROID.md said the adapter "is not part of an upstream release yet", which stops being true
once it is merged. The experimental status, the tested version and the platform stay in the
same paragraph.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Grok, merged into main while this branch was open, added itself to six lists that never named
Droid: the hosts in docs/PLUGIN.md's opening, the side-call commands in its privacy section and
its per-agent limitations, the run table in plugin/README.md, the `--host` values in hook.py's
docstring, and the module table in trace/README.md. Droid is now in each, with what the
branch already says about it.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
`droid_call` ran `[cli, "exec", ...]`, so on Windows an npm-installed droid would be a `.cmd`
shim that cmd.exe parses again, prompt included. It now starts through `program(cli)`, like
every other side call, and its throwaway directory ignores cleanup errors as theirs do.

`test_every_side_call_starts_its_cli_through_program` now covers droid_call (it failed before
this change), and the autouse `_no_model_calls` fixture stubs droid_call with the other calls.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The side call relied on its system prompt ("Do not call any tool") and on Droid's default
read-only autonomy, which on 0.233.0 still allows Execute, the web tools and every MCP tool
(`droid exec --list-tools`). It now passes `--only-tools Read`, Droid's own allowlist. The list
cannot be empty -- `--only-tools ""` reads as no filter and an unknown name is refused -- so it
keeps the one read-only tool, as Grok's `--tools read_file` does. With the call's own flags,
`--list-tools` reports Read as the only tool allowed, MCP tools included. A live side call
asked to run shell commands, write a file and search the web called no tool (its session log
holds no tool_use) and wrote nothing.

Droid has no setting for a call with no tool at all, and `droid exec` writes a session log of
its own, so side calls stay off by default (`droid_sidecall`, or TE_DROID_SIDECALL=1).
docs/DROID.md says so in "Side calls are off by default"; docs/PLUGIN.md, README.md and
plugin/README.md say the same, and test_droid.py pins the flag.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@thezelium
thezelium force-pushed the add-factory-droid-host branch from b97d529 to da7ed1b Compare November 7, 2026 12:02
@thezelium

Copy link
Copy Markdown
Author

Thanks for the review. Both side-call points are done, and the branch is rebased:

  1. Started like the other side calls. droid_call now builds its command with
    program(cli) (1d72ff2). test_every_side_call_starts_its_cli_through_program covers it; it
    failed before the change.
  2. Tools off with a real setting. The side call passes --only-tools Read, Droid's own
    allowlist (da7ed1b). Droid has no setting for zero tools (--only-tools "" means no filter,
    and an unknown name is refused), so it keeps the one read-only tool, as Grok's
    --tools read_file does. On 0.233.0, with the call's own flags, droid exec --list-tools
    reports Read as the only tool allowed, MCP tools included. Without the flag, the default
    read-only autonomy still allowed Execute, the web tools and every MCP tool. A live side
    call asked to run shell commands, write a file and search the web called no tool and wrote
    nothing. Because zero tools isn't possible and droid exec writes its own session log, side
    calls stay off by default, and docs/DROID.md says so.
  3. Rebased on main (0aa2aca), with Grok and Droid side by side; Grok is detected first.
    One extra commit (5ce4428) adds Droid to the six lists Grok joined, including
    trace/README.md and the --host list in hook.py. Add experimental Antigravity CLI (agy) host #147 is not merged yet; if it lands
    first, I'll rebase again.

python -m pytest tests: 812 passed, 16 skipped, 0 failed (Python 3.11 and 3.13, macOS). The
tests workflow on the new head is waiting for approval to run.

@thezelium

Copy link
Copy Markdown
Author

Previous comment was from Opus 5.5, hope everything is fine now!

@SakshamUboweja

Copy link
Copy Markdown
Collaborator

thezelium resolve the merge conflicts in this pull request before the merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Factory Droid 0.233.0 support: adapter ready and live injection verified

2 participants