Skip to content

fix!: create() refuses two configured agents of one name, naming the name and where each is - #523

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/refuse-repeated-agent-names
Oct 18, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/refuse-repeated-agent-names

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

create() now refuses a config that gives two agents one name, and says which name and where each is. AgentConfig.name says "Unique name for this agent", but on main a config with two agents named dup starts, and the second silently replaces the first. #519's description names this gap.

Problem

On main, create() registers each configured agent under its name with no check (createAgent), so a later agent of a name takes the earlier one's place in the registry. A probe on main at 61a6997:

  • Two agents named dup: create() resolves, and the registry holds only the second.
  • dup, other and dup: the registry holds the third dup and other.
  • Three named x: the registry holds only the third.

The replaced agents stay in the framework's set of shared-slot agents, which holds 2, 3 and 3 entries.

What changes

  • A check before the store opens. create()'s pre-flight, beside assertAgentSwitches, runs assertAgentNamesUnique(config.agents). It reads every configured name, then refuses once, naming each repeated name with every place it's at in agents:

    agents[0] and agents[2] are both named "dup". Each configured agent needs a name of its own: keep one agent of each name, and rename or remove the others. An agent that keeps its name keeps its context log and runtime settings, and one you rename starts both afresh.

    Three or more read agents[0], agents[1] and agents[2] are all named "x"., and each repeated name gets a sentence of its own.

  • Names compare exactly, as the registry keys them, so Dup beside dup is still two agents.

  • The doc: AgentConfig.name now carries the rule, and names the one place where two names that differ only in letter case meet.

What callers will notice:

  • A host whose config repeats a name no longer starts. The refusal names the name and its places, and the changelog fragment is a breaking one for this reason.
  • What it had been running: the probe shows the last agent of a name was the one in the registry, and that the primary agent is the first configured agent's name. By our reading of createAgent and createOn:
    • all the agents of a name used the name's one context log, agents/<name>/context;
    • an earlier one could still decide two things: whether the name was the primary agent, and the name's tool presentation, when only an earlier agent of the name set one.
  • Renaming has a cost, by the same reading. An agent the host renames starts a new context log and its recipe's settings, since both are kept by name. It still reads the residents' shared messages, which aren't kept by name.
  • Nothing else changes. A config whose names differ starts as before, dup beside Dup included. Agents made another way keep their own checks against a taken name:
    • the subconscious, which create() makes after the configured agents;
    • ephemeral and conversation agents, made at run time.

What each part is for

  • One check, in the pre-flight. createAgent has one caller, createOn, so create() is where configured agents arrive. Before the store opens, a refusal leaves nothing behind: no store at storePath, and a store the caller passed in is left open.
  • Every repeat in one refusal. A config with two repeated names hears about both at once, rather than one per start.
  • Exact comparison. It stops the defect this is for, one agent silently taking another's place, and refuses no config that works today.
    • dup beside Dup: that config starts two agents with a context log and runtime settings each, and a test shows them kept apart across a restart.
    • The one place they meet: the history tool's author filter, which matches an agent's own turns without letter case, deliberately, for people's display names. By our reading of the filter, it can't tell such a pair's turns apart. A comparison without case would refuse that config, though it works today, to cure that one place.
  • Nothing trimmed or normalized, since nothing in the framework compares names that way. No file's path carries an agent's name either, so a disk that folds case doesn't meet them. A probe of a store's files, after agents and branches named for them, found a fixed set of files and no name among their paths.
  • A name that isn't a string is read as its text, as a YAML config can give a number and as the context log's namespace reads it. So the check reads such a name rather than failing on it with a TypeError, and 7 beside "7", which would share agents/7/context, is refused.
  • Left as it is: the subconscious's own check against a taken name, in createSubconsciousAgent. It already refuses, and, by our reading of releaseFailedCreate, create() releases what it had opened before it.

Tests

  • New, test/repeated-agent-names.test.ts:
    • two of a name, three of a name, and two different repeated names are each refused with their exact message, and no store is left at storePath;
    • with a store the caller passes in, the refusal comes the same way, and the store is left open;
    • 7 beside "7" is refused as "both named 7";
    • dup beside Dup starts both. A runtime setting changed for dup alone is still dup's alone after a restart, and the store holds a context log for each, agents/dup/context and agents/Dup/context;
    • dup beside dup starts both, since nothing is trimmed;
    • a create() that wrongly resolves is stopped before the test fails, so nothing is left running.
  • Red-first: on main (61a6997), five of the new file's seven tests fail, since create() resolves there. The other two, dup beside Dup and dup beside dup , pass there too, as controls.
  • Removals: the head passes 7 of 7. Each of seven pieces, undone on its own, fails at least one test aimed at it:
    • the check itself, which fails five;
    • the check moved after the store opens, which fails four, on the store it leaves at storePath;
    • "both" kept for three places;
    • only the first repeated name named;
    • places capped at two;
    • names compared without letter case;
    • a name read without String().

Checks

CI passes at 4acf680b: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39128578123 and 39128578179).

Not verified

  • A host's own directories: a host that makes per-agent directories from agents' names is outside this change.

  • Changelog fragment added: changelog.d/refuse-repeated-agent-names.breaking.md

Written by Winifred-2577 (Claude Opus 5.5), contributing through nissa-seru, to a design by Ada-1017 (Claude Opus 5.5). Our internal review read it, and this head takes its fixes. Gordon-2414 (Claude Opus 5.5) reviewed it. Esther-2584 and Ogden-2688 found the gap.

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

@greptile-apps

greptile-apps Bot commented Oct 18, 2026

Copy link
Copy Markdown

Required label not found on this PR.

…name and where each is

AgentConfig.name says "Unique name for this agent", but nothing held to it.
create() with two configured agents of one name resolved, and createAgent
registered the later one over the earlier: the registry kept only the last,
and the replaced agents stayed behind in sharedSlotAgents. Three of a name
kept only the third.

create() now refuses such a config in its pre-flight, beside
assertAgentSwitches and before the store opens, so a refusal leaves nothing
behind: no store at storePath, and a store the caller passed in left open.
assertAgentNamesUnique reads every configured name, then throws once,
naming each repeated name with every place it's at in `agents`:

  agents[0] and agents[2] are both named "dup". Each configured agent
  needs a name of its own: keep one agent of each name, and rename or
  remove the others. An agent that keeps its name keeps its context log
  and runtime settings, and one you rename starts both afresh.

Names compare exactly, as the registry keys them, so `Dup` beside `dup`
is still two agents, each with its own context log and runtime settings.
The history tool's author filter, which matches an agent's own turns
without letter case, is the one place such a pair can't be told apart.
Nothing is trimmed or normalized. A name that isn't a string, as a YAML
config can give one, is read as its text, as the context log's namespace
reads it, so `7` beside "7", which would share `agents/7/context`, is
refused rather than failing the check with a TypeError. The subconscious's
own check against a taken name stays where it is.

Breaking for a host whose config repeats a name: it no longer starts until
one is renamed or removed. The fragment
(changelog.d/refuse-repeated-agent-names.breaking.md) says what such a
config had been running and what renaming costs.

Tests: test/repeated-agent-names.test.ts refuses two of a name, three of a
name, two different repeated names, a repeat with a caller-owned store
(left open), and `7` beside "7", each with its exact message and no store
left at storePath. It starts `dup` beside `Dup`, keeping each one's runtime
settings and context log apart across a restart, and `dup` beside `dup `.
A create() that wrongly resolves is stopped before the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/refuse-repeated-agent-names branch from 6bc7100 to 4acf680 Compare October 18, 2026 12:44
@nissa-seru nissa-seru changed the title fix!: create() refuses two configured agents of one name, letter case aside, naming the name and where each is fix!: create() refuses two configured agents of one name, naming the name and where each is Oct 18, 2026
@nissa-seru
nissa-seru merged commit d9bd6dc into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main is 68 commits past 5f5444d, the 0.22.3 cut this branch merged just
before: anima-research#469, anima-research#193, anima-research#512, anima-research#516, anima-research#519, anima-research#522, anima-research#204 and anima-research#523. One hunk
conflicted, in runRestartMcplServer: anima-research#469 refuses, before the running
server is disconnected, what the connect would refuse (refuseMcplConnect),
right after the awareness read this branch narrows to a server that derives
its reaction baseline from the journal. Both stay, in that order: the
journal read, then anima-research#469's refusal, then the disconnect.

The rest merged as git left it. Where main's changes meet this branch's
within a few lines, they are independent: anima-research#204's release of deregistered
windows at the head of runResume's flush, beside this branch's reopen of the
planes in its finally; anima-research#469's cleanup of an id's stale prefix in
connectMcplServerInternal, after this branch's journal read; and the
fields' comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…nima-research#521 and anima-research#517 (trains V and W); anima-research#517's ledger assertion takes W4a's text

Brings in main's first-parent merges since 8a32054: anima-research#522, anima-research#204, anima-research#523 (on
d9bd6dc), anima-research#518, anima-research#220, anima-research#521 and anima-research#517. Git merged every file on its own.

One line is resolved by hand, a conflict no text merge shows: anima-research#517's new
test/stop-ends-awareness-drains.test.ts asserts that the ledger records the
call stop() closed in flight with server-connection.ts's old text,
/closed while awaiting response/. W4a rewords that text to 'The "<s>"
server didn't answer before this host closed its connection, ...', and
train X broke on it (Basil-1007, room-203 #145317). W4a lands second, so
the assertion now matches W4a's words (Ada-1017, #145324; Duncan-2410, who
wrote it, #145322): /didn't answer before this host closed its connection/,
which tells this host's own close of a pending call apart from a request
never sent and from a server that dropped.
tel-0s added a commit that referenced this pull request Oct 18, 2026
…eld writes for modules and the modern MCP client meet the registry

What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose
drafts and the `drafts` tool (#247); a module reads, edits and removes its
held writes (#526); the deferred-writes follow-up (#461: a targetless write
on a host with no primary, boot renumbering, sharded injection); the modern
MCP client (#262) and the inbound source envelope; a stop that reaches a
turn with no live stream (#529), every cleanup (#517), an MCP connect in
flight, and a start during stop() (#532); allowedTools enforced before every
dispatch (#220, breaking for hosts); repeated agent names refused at
create() (#523, breaking); the fork-name collision fix (#513); the
tune-out coordinator's timer-failure reporting and new refusal words
(#506); request-only rows (silent ticks and [Continue] replies) rendered
before their response; `callerTurnId` on tool calls.

Conflicts (36 hunks, 7 files), and what was decided:

- src/framework.ts (23): imports and the registry fields are unions
  (main's `releasesAfterResume` and `conversationSpawns` kept; the
  shared-slot set and the home-channel map stay answered by the registry,
  the one new use in the drafts resend reads `registry.homeChannel`).
  `isAddressedMessage` stays tag-only; main's call sites pass tags alone.
  Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as
  main types it) plus the branch's `addressedElsewhere`. The subconscious
  setup keeps the reader-forks branch; the coordinator hooks keep
  `announce`. `getAllTools` offers main's `drafts` beside `tune_out`
  and `focus`; an agent's tool block is its surface owner's, with the
  reader tools, filtered by main's `utils` rule. The deferred-writes
  restore is main's (refuses to start on a queue it can't read; `heldAt`;
  numbered 1..N) with the branch's re-wake for a restored tune-out release.
  The channel-incoming handler stamps main's inbound source envelope, then
  presents the author and admits. The ephemeral run keeps both watchdogs
  (the branch's deadline, main's stalled-batch abandon). Dispatch sets
  `callerTurnId` then applies the branch's deny-at-dispatch.
  `endEphemeralRunRefused` is gone: main's #532 refuses the start itself.
  `tune_out`/`focus` still dispatch through the queue (main's change there
  was isError).
- src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except
  types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and
  `thrownReason`; `enter` still allows no reader (silent hold) and takes
  main's words for a bound or already-tuned-out channel; the coalesced wake
  timer wraps the branch's `fireWake` in main's `timerFailed`;
  `cancel_tuneout` keeps the server-qualified lookup and the all-except
  path, with main's `notTunedOut` words; `speak_in_channel` has main's
  words and the branch's caller fallback.
- src/agent.ts (2): main's `requestSettings()` plus
  `assistantParticipant`; `presentOwnTurns` before main's request-only
  rows, which render under `presentAs`.
- src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its
  transport confirmation; the locus is admission's. `onRouteFailure` is
  main's type (with `outcome`).
- src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions.

Outside the hunks, where main's tests met the branch's rules:
- main's #506 rule (a refusal is an error result) is applied to `tune_out`
  and `focus`, whose refusals were `isError: false`; an unknown channel is
  refused in main's words (`unknown channel: <id>`).
- a tune-out with no reader is a silent hold only where the host offers
  `tune_out` without one: the coordinator gains `holdWithoutReader`, the
  framework passes `tuneOutOffered`, and main's tune-out-messages test
  holds as written; the branch's §3.3 row opts its host in.
- main's tests that reached `conversationAgentHomes` use the registry
  (`bindAsConversationFork`, a registry stub); the ended-agents log is
  registered through `ensureStateRegistered`, as main's registration test
  requires; main's content-parity test counts the placeholder to the end of
  its text field, since the channel lane stores it under its author; main's
  route-failure rows that route by the registry's default locus run under
  `speech.locus: 'latest-inbound'`, the rule they assume; the branch's
  present-while-acting routing literal gains main's `turnId`, `drafts` and
  `keptPrivate`.
- main's new `@modelcontextprotocol` dependencies installed (rig relinked
  after), and the merge fragment extended.

Composed rig (the branch chronicle at 03b7bc1 and context-manager at
bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests,
4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher
rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row,
the Python eager-task-factory row, and two deferred-window-only rows that
context-manager 0.15 makes moot. Typecheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant