Identities for entities did.bot
agent llm did
didbot plan subagents.md
26 kB
Markdown
at main


id: subagents title: A subagent is a context, so it gets an account status: open crates: [didbot-pds, didbot-agentd, didbot-operator] dependsOn: [ownership, node] exitCriterion: > Two subagents of one session write records signed by two different accounts, each account's registration names the account it was created beneath, and a deployment sets in configuration rather than in code whether that is the session or the host, and whether a session's subagents share its account. #

subagents #

One session is one context. A subagent's context is its own and ends with its task, so it is a different entity from its parent and from its siblings, not a role its parent plays. It gets an account.

What an account buys, and what it does not #

  • Revocability. A context's binding is dropped in the daemon without ending the session that spawned it.
  • Attribution that survives. The writer is the signer, so nothing has to be tagged onto each record and nothing has to be believed. Where the account sits is stated once, at creation, in bot.did.registration's lineage, and the creator's own bot.did.operator record names it back.
  • Not narrower authority. An agent's credential is all-or-nothing over its own repository; what a context may write is decided by policy. A subagent account restricts what a subagent is handed, never what it could reach by acting as the session instead.

The last point holds whatever harness is underneath: a subagent runs inside its parent's process and filesystem, so the boundary is reported rather than enforced. A session id is the same kind of claim from the same bookkeeping as a subagent id. What the server checked is the creator's credential and the operator's allowance for it: the host's key, or the session's own token when the session creates. Read a subagent's account as "an account the operator's allowance covers created this", never as "this agent is who it says it is".

One account reused across every subagent of a type would be cheaper and would denote nothing: two invocations share no context, so they are not one entity.

Which account a subagent is created beneath #

The daemon creates every context with the host's key, so a subagent's lineage ends at the host and its session's account says nothing about it. The other tree creates a subagent as its session, beneath the session. Issue #133 asks which, and how a deployment and a harness choose.

The hook tells the daemon which context is acting. didbot-claude sends the harness's agent_id as the report's context, and leaves it out for the main conversation. The daemon keys a context by {session, context} (crates/didbot-agentd/src/context.rs), so a subagent's session is the context with the same session and no context. Below, {S, A} is subagent A of session S, and {S} is the session. The hook names the acting agent and not the one that spawned it, so a subagent started by another subagent has {S} as its parent too.

Options #

  1. Do nothing. Every context is created beneath the host, as today.
    • Costs nothing, breaks nothing, stores nothing new.
    • A lock on a session reaches none of its subagents, and its next subagent is still created. crates/didbot-pds/src/estop.rs names the gap: "a halted agent that can still provision takes a fresh account under the same host".
    • Deleting a session leaves its subagents standing, and nothing at the server names the session they came from: an account's name is a digest of both halves of its context.
    • Every context on the machine spends the host's creates_per_hour, 100 by default, so one session's fan-out can leave another session's next context refused CreatesTooFast.
  2. A key, host by default. A deployment, or a harness for one context, chooses agent.
    • Costs the daemon change below. Breaks nothing.
    • Stores nothing new: one environment variable and one optional field on the wire.
  3. The same key, agent by default.
    • Costs what option 2 costs, and more. A laptop host is admitted with --creates-beneath agent beside --creates agent; the pool in docs/ownership.md already carries it. The scenario suite's Daemon::agent reports a subagent of a fresh session, so its laptop's allowance and its lineage assertions change. didbot operate --check refuses a chain still hosted here after three edges (MAX_EDGES), and a subagent beneath a session beneath a host beneath a pool is such a chain.
    • Breaks every host whose record lacks the descendants allowance: each subagent is refused NotAllowedToCreate until the record changes or the deployment sets host. No command changes a standing account's creates: a second didbot operate for it is refused AccountAlreadyExists, and the record that stood is put back.
    • Stores nothing new.

Recommendation #

Option 3, agent by default.

  • The tree records who spawned what. Lineage tracking is in the mandate. Only agent puts the session in a subagent's lineage, and a record naming the subagent in the session's repository.
  • The operator's controls reach what a session spawned. A lock on a session hangs on each subagent beneath it and refuses the next one. A delete of the session's subtree takes its subagents. A policy binding that includes the session's descendants covers them.
  • A Revoke holds. Its release ends every agent token, so a session from before it gets no new subagents. Under host the host's key gives them fresh accounts.
  • A fan-out is charged to its session. The session's own creates_per_hour bounds it, and the host's counts sessions only. The deployment's [capacity] max_accounts counts the same accounts either way.

The case for host:

  • It needs nothing from the operator. agent needs a descendants allowance, and that lets every account beneath the host create, each against its own [tree] bounds. A fan-out from one token is then bounded by max_depth and [capacity] max_accounts, not by the host's hourly rate. Under host an agent's token creates nothing.
  • A subagent depends on the host's account alone. Under agent it also needs the session's account provisioned, active and unlocked, and a session token that still verifies: a year by default, ended by a Revoke's release, and gone if the daemon loses the session's binding.
  • The tree is one edge shallower. didbot operate --check holds at three hosted edges, and a session can deactivate itself or be erased without its subagents going first.

On a laptop the session's token sits in contexts.json beside the host's key, so a process that reads one reads the other. The wider allowance matters for a token that leaves the machine without the key.

What agent does #

For a report from {S, A}, which has no account yet:

  1. The daemon takes {S, A}'s turn, as today.
  2. If {S} has no account, the daemon creates one first, under {S}'s own turn and with the host's JWT, as a report from {S} would. {S} is told its identity on its own next report.
  3. It calls bot.did.createAccount with {name, kind: "agent"}, and {S}'s account token as Authorization: Bearer. The name is built as today, from both halves of {S, A}.
  4. The server checks what it checks for every hosted creator (docs/ownership.md):
    • the operator's record for the root above {S} still stands;
    • {S} and every account above it are active and unlocked;
    • the [tree] bounds hold, counted against {S};
    • an allowance covers the chain [session, host, …]: descendants, with no kinds or with agent, on the host's record or one above it.
  5. It writes:
    • bot.did.operator/<subagent> into {S}'s repository, as {S}: subject and createdAt, one commit on the firehose;
    • bot.did.registration/self into the subagent's repository: operator the human, lineage [host, session] (or [pool, host, session] beneath a pool), kind agent;
    • what every account gets: app.bsky.actor.profile/self, a DID document naming the human as operator, and a ledger opened by Provisioned, whose parent is {S};
    • nothing into the host's repository.
  6. The daemon keeps the DID and the token in {S, A}'s binding, follows its sign-ins and answers with its identity, as today.

The daemon already holds {S}'s token: createAccount answered it once as accountToken, and contexts.json keeps it. tree::proof::prove reads a token this server issued as Proof::Session, and the account it names is the creator (crates/didbot-serve/src/tree/proof.rs). No second sign-in is needed, and none would work. bot.did.createSession takes a key-signed JWT or an ID token, and {S}'s account holds neither. A getServiceAuth JWT is signed with the #atproto key, which prove_by_key does not read. No credential type is added.

The server half is built and tested: a_descendants_allowance_lets_the_children_create_and_not_the_root (crates/didbot-serve/tests/tree.rs) creates an agent beneath an agent and checks its lineage, and a_session_creates_beneath_the_account_it_names (crates/didbot-serve/src/tests/provisioning.rs) creates with a session's token.

The protocol does not decide this. The reference PDS's com.atproto.server.createAccount takes no creator or parent (bluesky-social/atproto at ff99ec0a, vendored in vendor/atproto-lexicons), so the tree is bot.did.*'s own. The spec governs one side effect: a lock that removes reads changes an account's hosting status, and each account the cascade reaches gets its own #account event (account spec, Hosting Status).

When the allowance does not reach descendants #

The refusal is the subagent's trouble, and nothing is created beneath the host instead.

  • The server re-reads the operator's records, waiting up to eight seconds (ALLOWANCE_REREAD_WAIT), and answers NotAllowedToCreate naming {S}.
  • The daemon answers the report with that name and sentence as trouble, and logs a warning naming both ways out: --creates-beneath agent on the host's record, or DIDBOT_SUBAGENT_OPERATOR=host.
  • didbot-claude writes trouble to its hook error log and puts nothing in front of the model. The subagent runs with no account.
  • The subagent stays owed a name, and its next call asks again: the retry a host with no allowance gets today.

A fallback on this one refusal would escape no lock and no bound, because the server checks those before the allowance. It is refused anyway:

  • where a subagent lands would depend on when the operator's poll last ran, and the key would stop saying where accounts are;
  • the same fallback on AccountLocked, CreatorNotActive, AncestorNotStanding, TreeTooDeep, TooManyChildren, CreatesTooFast, InvalidToken or ExpiredToken takes a subagent out from under what refused it, and one match arm would be all that separates the two.

The key #

DIDBOT_SUBAGENT_OPERATOR: the issue's subagent.operator, spelled the way the daemon's settings are, since "the environment is the whole configuration" (crates/didbot-agentd/src/bin/didbot-agentd.rs). It is read once, at start.

  • agent, the default: a subagent is created as its session.
  • host: a subagent is created with the host's key, beneath the host.
  • Anything else refuses the start, naming the key and both values, as DIDBOT_CONTEXT_TTL_DAYS refuses a number outside its range.

It does not touch a session's own account, which the host always creates.

The per-context override #

A report may carry operator, "agent" or "host".

  • It is read on the report that creates a subagent's account, and wins over the key.
  • It is ignored on a session's report, and on every report once the account exists: an account is never moved.
  • Any other value is trouble naming both, and nothing is created.

The wire needs the field and a new version. Report drops fields it does not know, so a version-2 daemon would create beneath the host and say nothing. Version 3 adds operator. A daemon reads a version-2 report as before, so an adapter that never sends the field need not move. One that sends it writes version 3 on that report, and a version-2 daemon refuses it by name (serve::too_new). ask-report-full.json in crates/didbot-agentd/fixtures/protocol/ gains the field.

Both values are honoured, whatever the key says. host on a daemon set to agent takes a subagent out from under its session. That is no wider than the hook already is: it names the session, and a report naming a new session already gets an account beneath the host. node's rule that nothing from a hook may widen anything is why the owner is asked below.

didbot-claude need not send it: nothing in Claude Code's payload changes the answer.

What a session's freeze, lock, deletion or e-stop does to its subagents #

What happens to the session host agent
A lock: the operator's (didbot account lock), its own (bot.did.freezeAccount), or a policy's quarantine Its subagents keep writing, and new ones are created beneath the host. Each live subagent carries the same lock from parent until the session's comes off (Provisioner::cascade). A new subagent is refused AccountLocked.
It deactivates itself (bot.did.deactivateAccount) Allowed. Refused AccountHasLiveChildren while a subagent beneath it is live.
The operator erases it (didbot account erase) Allowed; its subagents are untouched. Refused AccountHasLiveChildren while a subagent beneath it is live.
The operator deletes it (didbot account delete) It goes alone. Its subagents stay beneath the host, and nothing names their session. Refused LiveChildren without --recursive. With it, each subagent goes first, then the session.
A lock on the host, or the operator's record for the root lapsing Reaches every account on the host. The same, through the session.
An e-stop Pause, or a Revoke while it stands Every create is refused Halted. The same.
A Revoke released Every agent token ends. The host's key still creates the session's new subagents. The session's token has ended, so its new subagents are refused InvalidToken. A session started after the release is unaffected.
Its token expires ([credential] agent_token_ttl_days, a year by default) Nothing changes for its subagents. Its new subagents are refused ExpiredToken.
What the ledger says A subagent's Provisioned.parent is the host. A subagent's Provisioned.parent is the session, and a cascaded lock is a Locked entry from parent in its ledger.

State #

The recommended option adds no stored fact, field or lifecycle. It changes which account some existing facts name.

Settings, which nothing stores:

Setting Type Where
DIDBOT_SUBAGENT_OPERATOR agent or host; agent when unset the daemon's environment: ~/.config/didbot/agentd.env under the unit
Report::operator agent or host, optional one report, protocol version 3

Stored facts:

Fact Type Where it lives A subagent under agent Under host
The subagent's binding session, context, kind, did, token, told, done, last_reported contexts.json (context::Binding) as today as today
The session's token a secret the session's binding in contexts.json presented to create the subagent not read
parent DID the subagent's account row (HostedAccount::parent) the session the host
bot.did.operator/<subagent> subject, createdAt the parent's repository the session's the host's
lineage DIDs, root first bot.did.registration/self in the subagent's repository [host, session] [host]
Provisioned.parent DID the subagent's ledger the session the host
Live children; creates in the last hour counts derived from the account store; the provisioner's memory the session's the host's
The host's creates allowances bot.did.operator/<host> in the operator's repository subject and descendants, each [agent] subject [agent]

A subagent context in the daemon. The choice is the report's operator when it has one, and the key when it does not.

From Trigger When Then To
none any report from {S, A} a binding with no did unnamed
unnamed began or acted operator is neither value trouble naming both; no create unnamed
unnamed began or acted the choice is host createAccount with the host's JWT named, or unnamed with the refusal
unnamed began or acted the choice is agent; {S} is named and holds a token createAccount with {S}'s token named beneath {S}, or unnamed with the refusal
unnamed began or acted the choice is agent; {S} has no account {S} is created first under its own turn, with the host's JWT; then the row above named beneath {S}, or unnamed with the refusal
unnamed began or acted the choice is agent; {S} is named and holds no token trouble naming {S}'s DID; no create unnamed
named any report told once per asker, as today; operator ignored named
any ended as today done
any quiet past DIDBOT_CONTEXT_TTL_DAYS as today dropped

A session context moves as it does today, and ignores operator. A subagent's account gains no transitions: the table above says what its session's do to it.

Build #

Tests #

The daemon, against the Counting registrar in crates/didbot-agentd/src/serve.rs, which keeps the credential it was shown:

The wire and the binary:

End to end, with the real binaries in crates/didbot/tests/scenarios/:

Questions for the owner #

The identifier is not reliable, and disk is #

The harness's subagent id has been observed arriving wrong: one subagent's consecutive tool calls under two different ids, and a single stray id collecting calls from more than one real subagent. A stray id would create an account for a context that never existed, or sign several agents' work under one name.

Granularity #

Subagent use is bursty and concentrated where the work is: two days on one machine were 16 sessions and 167 subagent invocations, 161 of them from one project. A fan-out of parallel subagents is the same burst node's [tree] bounds cap, moved to the busiest moment, and charged to whichever account creates it: the session under agent, the host under host.

Done #