feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(plugins): add Cordis-scoped dynamic tool contributions - #4443

Open
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions
Open

feat(plugins): add Cordis-scoped dynamic tool contributions#4443
xxhZs wants to merge 8 commits into
apache:mainfrom
xxhZs:feat/plugin-tool-contributions

Conversation

@xxhZs

@xxhZsxxhZs commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR makes Host plugin Tools a first-class, Context-scoped runtime contribution and allows the effective model Tool surface to change safely between logical model steps in the same Turn.

  • add ctx.tools.register() for trusted Host plugins, with registrations owned by the registering Fiber and the Plugin Platform transaction
  • resolve Profile and Session Tool layers through one scoped registry; exact Session registrations shadow Profile registrations, while Host-owned core Tools cannot be shadowed
  • use the same composition path for statically activated packages and Tools registered or disposed at runtime
  • refresh the effective Prompt and Tool surface before every logical model step
  • keep the current provider request and every physical retry immutable
  • execute returned Tool calls against the exact Tool snapshot advertised to that provider request
  • preserve Maka ToolRuntime as the authority for validation, permissions, sandboxing, settlement, telemetry, and durable provider-request capture
  • emit Cordis-style tools/change notifications and roll publication back atomically if a listener rejects the change

Runtime semantics

Tool changes take effect at model-step boundaries, never in the middle of an in-flight provider request:

  1. model step N is dispatched with Tool surface N
  2. a Tool call registers, replaces, or disposes a Fiber-owned plugin Tool
  3. all Tool calls for step N settle
  4. step N+1 re-resolves scoped Tools, Prompt fragments, Tool availability, schemas, Code Mode bindings, gating, repair names, and request-shaping diagnostics
  5. the provider receives the new surface

This produces deterministic semantics:

  • registration becomes visible on the next logical model step in the same Turn
  • disposal removes the Tool on the next logical model step
  • an active invocation is allowed to drain before its registration is removed
  • retries of one logical model step keep the same frozen Tool surface
  • bound child lists and explicit Tool profiles remain exact capability ceilings and do not inherit plugin additions
  • replacing a same-name Tool creates a new contribution identity and does not inherit the retired generation's tool_search activation

Composition and persistence

RunComposition keeps its existing persisted v1 schema and semantics. It is written once before the first provider dispatch and remains the immutable initial Run baseline, including composer identity/revision, Prompt and Tool hashes, source revisions, provider options, Tool names, and context-window facts. Existing databases decode without migration.

Dynamic Prompt and Tool state is recorded separately. Before each logical model-step dispatch, Runtime durably resolves a request_composition_resolved snapshot containing:

  • Prompt source revisions and hash
  • Tool catalog and availability hashes
  • active Tool names
  • canonical provider-visible Tool schemas
  • provider-options hash

The current request and all of its physical retries bind one immutable requestCompositionId. Surface changes affect only the next logical step. Snapshots are indexed by their complete canonical surface across the whole Run, including after reopening the Run, so A → B → A reuses A instead of appending another full schema record. ModelCallAttempt references preserve the per-step timeline even when a prior snapshot is reused.

Both commits remain fail-closed before provider dispatch. A required Request Composition write bypasses the best-effort store latch, so a transient trace-write failure can self-heal instead of permanently blocking the rest of the Run.

This preserves the original Runtime Host durability and audit guarantees while generalizing the invariant from “one immutable surface per Run” to “one immutable initial baseline plus immutable logical-step surfaces.”

What this enables

The PR itself provides the runtime and Plugin Platform primitives, not a built-in Tool generator. On top of these primitives, plugins can implement:

  • installable Tool packages with transactional install, activation, stop, and uninstall
  • Profile-wide Tool packs and Session-specific overrides
  • feature- or state-dependent Tools mounted as child Fibers and removed with Fiber disposal
  • same-Turn flows where one Tool enables a capability and the model calls that newly available Tool on the next step
  • searchable/deferred large Tool catalogs without making every schema provider-visible at once
  • external self-extension workflows where a trusted authoring plugin generates an ephemeral Tool, mounts it in a child Fiber, iterates it, invokes it, and disposes it afterward

The last item requires an out-of-tree authoring/sandbox plugin; this PR deliberately does not add model-authored code execution to Maka core.

Verification

Automated regression

  • full workspace npm run typecheck
  • full workspace npm run lint
  • focused Core, Storage, Runtime, Runtime Host, Tool availability, Plugin Platform, and real provider-wire regression: 348/348 passed
  • persisted v1 Run Composition decode and SQLite reopen coverage
  • request surface sequence A → B → A with full-Run and reopened-Run deduplication
  • retry coverage: all physical retries reuse one request-composition identity
  • explicit Tool-profile and bound-list ceiling coverage
  • same-name contribution replacement does not inherit prior search activation
  • package lifecycle coverage: disk install, activation, invocation, disposal, uninstall, rollback, conflict handling, and active-call drain
  • live in-repo scenarios for dynamic inventory and weather-package install/invoke/remove

Real Maka + real model

The live scenarios were exercised with DeepSeek V4 Flash on the same dynamic Tool implementation before the compatibility follow-up:

  • installed a weather Tool package through the real Plugin Platform and received committed + converged
  • discovered and invoked the Tool through the model-facing Tool catalog
  • uninstalled it with committed + converged + cleanup complete; the catalog returned to zero plugin Tools
  • exercised a same-Turn dynamic inventory flow: enable → next-step discovery/invocation → disable → later-step absence
  • confirmed the previous Run Composition changed after resolution failure no longer occurs

Out-of-tree compatibility and capability probes

These probes are not included in this PR; they use only the public behavior introduced here:

  • adapted all 59dsh-quant Tools without modifying Maka, installed them through the real Plugin Platform, invoked a deterministic calculation, then uninstalled them with 0 Tools remaining
  • ran a real-model self-extension prototype where the model authored one ephemeral access-log Tool, mounted it as a child Fiber, iterated it from v1 to v3 after observing parse failures, loaded it on later model steps, invoked and verified it, then stopped it; the Turn completed after 18 Tool calls and the Tool catalog returned to zero

Together these checks demonstrate that the PR supports both ordinary plugin Tool packages and dynamically changing same-Turn Tool surfaces, while keeping request snapshots, retries, execution, cleanup, and durable audit facts coherent.

Boundaries

  • only trusted Host plugins can contribute Tools
  • changes are visible on the next logical model step, not the current in-flight request
  • model-authored Tool generation, code sandbox policy, artifact persistence, quotas, and approval UX remain the responsibility of an external plugin or a future dedicated feature
  • exact child/bound Tool ceilings and explicit Tool profiles do not automatically inherit dynamic additions

@github-actionsgithub-actionsBot added the effort/XL Over 1000 readable lines label Sep 1, 2026
@likun666661

Copy link
Copy Markdown
Member

I think the existing tool_search contract changes how this feature should be framed and where the plugin integration should land.

Maka already has a mechanism for changing the provider-visible Tool surface on the next logical model step:

B = executable Tools bound to the current Run (the capability ceiling)
A = Tools activated by search in the current Turn
D = the fixed direct baseline
R = Tools required by current Runtime state
visible(step) = (D union A union R) intersect B

ToolAvailabilityRuntime derives an immutable backend-scoped catalog/index from the final executable binding. tool_search mutates the Turn-owned activation map, the current step-start snapshot stays unchanged, and the selected schemas enter the next provider request. The execution guard, retry behavior, schema budget, and Turn cleanup already enforce the rest of that lifecycle.

This was an explicit part of the agreement in #3752:

  • the Tools actually bound to the current Run are the capability ceiling;
  • search never binds a new executable Tool and never escapes boundTools;
  • activation is monotonic within the Turn and cleared at Turn completion;
  • ToolAvailabilityRuntime owns an immutable catalog/index while TurnScope owns activation.

#4098 then simplified this further: the final executable binding is the only availability authority, every non-direct bound Tool is deferred by default, and groups are only search metadata.

Against that existing contract, this PR currently does something broader:

PluginToolService
-> resolve the complete Tool set before each logical step
-> rebuild ToolAvailabilityRuntime / MiniSearch
-> recompute the whole provider Tool surface

That duplicates part of the existing availability mechanism and, more importantly, changes the capability model from “the Run binding is the ceiling” to “the ceiling may expand or contract during the Run.” I think that is a separate architectural decision from allowing plugins to contribute Tools.

There is also a production-semantics gap in the current tests. The main dynamic plugin tests construct the backend without toolAvailability, so they exercise full-surface mode: a newly registered Tool schema becomes directly visible on the next step. An ordinary Interactive Run does supply search availability. Since plugin Tools are not in the direct baseline, they will be deferred automatically (currently under the fallback other group). The production path should therefore be closer to:

install/activate plugin
-> Tool becomes discoverable in the bound search catalog
-> model calls tool_search
-> complete schema becomes visible on the following step

Suggested minimal integration

I would keep the valuable Plugin Platform work in this PR:

  • ctx.tools.register();
  • Fiber/transaction ownership;
  • Profile inheritance and Session shadowing;
  • Host-owned Tool collision protection;
  • active invocation drain;
  • inspection/query support.

But I would connect it to Runtime through an immutable binding snapshot rather than a live resolveTools() call on every step, for example conceptually:

interfacePluginToolSnapshot{revision: stringtools: readonlyMakaTool[]groups: readonlyToolGroup[]release(): void}

The Interactive Run Composer would take one plugin snapshot while constructing the backend/Run, merge its Tools and group metadata into the final executable binding, and let the existing ToolAvailabilityRuntime handle deferred-by-default discovery, bounded search, next-step activation, same-step gating, permissions, sandboxing, durability, and telemetry.

Plugin package/entry identity can naturally provide search-source metadata, for example:

plugin:<extensionId>
- weather_forecast
- weather_alerts

On install/enable/uninstall, the Plugin Platform updates canonical state and invalidates idle backends. The active Turn keeps its pinned snapshot; the next Turn receives the new binding. Uninstall can remove the entry from future snapshots immediately while reporting cleanup pending until snapshot references and active calls drain. This fits the existing cleanup: complete | pending contract.

This path would avoid:

  • rebuilding the whole MiniSearch index on every model step;
  • re-resolving the system prompt for a Tool-only feature;
  • changing immutable Run Composition into per-step composition epochs;
  • widening exact boundTools / tool-profile ceilings;
  • the Run Composition v1 -> v2 persistence migration introduced here.

If same-Turn install-and-use is a hard requirement

That is a valid but stronger feature. It should be stated as an explicit change to the #3752 capability contract. The ceiling would no longer be a fixed executable Tool set; it would become a fixed set of trusted Tool sources whose contents may change.

Even in that design, I do not think the best seam is “re-resolve every Tool before every step.” A more coherent extension would make the Plugin Tool registry a dynamic source behind tool_search:

  • static bound Tools keep the current cached backend index;
  • tool_search queries the Session-scoped plugin source;
  • a match activates an exact contribution identity such as (entryId, generation, schemaHash);
  • only activated dynamic Tools are merged into the following step snapshot;
  • removal/replacement is reconciled by contribution identity, not name;
  • re-registering the same name does not inherit an old activation;
  • the current provider request and all of its physical retries keep the same step-start snapshot;
  • the system prompt remains stable.

This preserves the existing lazy-loading model and makes the additional authority explicit instead of introducing a parallel dynamic-composition path.

My recommendation is therefore to start with the snapshot/binding integration and next-Turn mutation semantics. If the required product behavior is specifically “the model installs or authors a plugin and invokes it in the same Turn,” that should be separated and reviewed as a dynamic Tool-source/capability-ceiling change. Without that requirement, most of the per-step composition and persistence work in this PR appears unnecessary.

The key decision to settle before continuing is: must plugin installation or removal affect the Turn that is currently running?

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 271fa3e3. Thanks for this — the scoped registry is the right shape, and I like that Profile/Session layering resolves through one registry instead of two lookups. The Fiber-owned lifetime and the Plugin Platform transaction hook are also the parts I'd have worried about most, and they hold up: I traced a dispose during an in-flight call and the retired entry throws rather than silently swapping an implementation, so a Turn can't end up executing a Tool it never advertised.

I also want to say up front that I'm not questioning the per-step epoch design. recordRequestComposition's own comment says it's matching DSH request/header epochs, and re-sampling the surface between logical steps is exactly what makes live registration meaningful. Everything below takes that as given.

Four things, one of which I think should block merge — but it's the one that's easiest to fix, because it isn't part of this feature at all.

P0 — the RunComposition v1→v2 change will stop the Host from starting on any existing install. This is separable from the plugin work, and I think dropping it from this PR is cheaper than adding a migration. Details inline on run-composition.ts.

P2 — Host-owned core Tools cannot be shadowed isn't true on the toolProfile path. The summary states this as a property of the change, and it holds for boundTools, but the two guards in interactive-run-composer.ts are checking different things on adjacent lines. Inline.

P2 — the best-effort store latch now sits in front of provider dispatch. You followed the file's existing pattern here and I don't think that was the wrong instinct; the difference is what's downstream of it. Inline.

P2 — epoch equality only compares against the previous epoch, so an oscillating surface re-appends in full. Inline, with the numbers.

A couple of smaller notes I don't think are worth inline threads:

  • toolNames is capped at 256 and toolSchemas at 512, but both are derived from the same activeToolsForRequest, so the real cap is 256 and nothing upstream enforces it. Since the summary pitches adapting large catalogues (the 59-Tool dsh-quant example), it's worth aligning those two and deciding whether hitting the cap should truncate the record or fail the step. Same question for MCP tool descriptions — boundedString(schema.description, 16_384) in the snapshot, no length bound at all in mcp-tools.ts where the description comes straight from the server. I have no evidence a real server exceeds 16 KB, so this is a "which side should give" question rather than a reported bug.
  • toolAvailabilityHash in the per-step epoch reads this.input.toolAvailability, frozen at backend construction, while the catalogue is now re-sampled each step through resolveTools(). The real change is already covered by toolCatalogHash/toolNames/toolSchemas, so nothing is wrong — the field just can't do what its name promises in a per-step record.

One thing worth knowing about plugin-tool-service.test.ts: the conflict case ('desktop-ui and Host-owned Tool conflicts fail closed') calls tools.resolve('alpha', [tool('Read', 'host')]) with an explicit core list, but production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700. So the guard that test exercises never runs in production, which is why the shadowing path below is green. None of the three test files go through createInteractiveRunComposer; one test that does would cover the second finding directly.

Evidence boundary: I read pr4443 against origin/main and ran the PR's own run-composition.ts + record-schema.ts in isolation to check the decode both directions. The startup consequence in the P0 is traced through the call chain and through Desktop's startDesktopRuntimeHostWithRecovery, not reproduced end to end — I did not stand up an old database and watch a Host fail to start. The 44 KB/epoch figure is measured from 40 real Tools in a main build, so it excludes MCP and plugin contributions and is a lower bound. I did not run the test suites.


AI-assisted review: drafted with Maka; I verified the decode failure, the two guards, the latch's callers, and the shadowing path against the branch source myself.

Comment threadpackages/core/src/run-composition.ts Outdated
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';

export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 — every existing database has v1 rows, and v2 rejects them on a read path that runs during Host startup.

Two independent reasons a v1 row fails now: the version literal moved to 2, and RUN_COMPOSITION_SHAPE dropped sourceRevisions, baseSystemPromptHash, toolCatalogHash, toolAvailabilityHash and toolNames with an empty optional list, which hasExactShape treats as excess keys. I pulled this file and record-schema.ts out and ran them:

v1 record REJECTED Invalid Run Composition snapshot schema
v1 shape @ version 2 REJECTED Invalid Run Composition snapshot schema
v2 record decoded OK
main decoding v2 REJECTED Invalid Run Composition snapshot schema

So it breaks in both directions, and bumping only the version isn't enough to make an old row readable.

Every real Run has one of these: commitRunComposition is on beforeRunProviderDispatch unconditionally (execution-model-composition.ts:331), and the commit that introduced v1 is an ancestor of v0.2.0-incubating-rc1.

The consequence isn't a silent downgrade. decodeRunCompositionSnapshot throws, isRunCompositionSnapshot returns false, and agent-run.ts:707 throws Invalid AgentRun header schema. That surfaces during recovery with no per-row catch anywhere on the way:

agent-run-store.ts:538 rows.map(row => decodePersistedAgentRunHeader(...)) // bare map
-> listSessionRunsForRecovery -> hosted-execution-recovery -> prepareRecovery
-> execution-composition.ts:1776 -> host-kernel.ts:387 await this.#composition.recover()

host-kernel.ts:387 has a finally and no catch, so the rejection crosses Promise.race and #state never reaches 'ready'. On the Desktop side, startDesktopRuntimeHostWithRecovery rethrows anything canRepairManagedRuntimeHostStartup doesn't recognise, and that predicate only accepts RuntimeHostStartupError with one of seven deployment reasons — a raw schema error isn't among them, so the repair prompt isn't even offered. One old row, no start, no in-product way out. listSessionRunsPage, listSessionRunsBounded and readRun take the same path, and readSqliteAgentRunEvents:979 reads the header first, so events go with it.

The cheapest fix is probably to take this out of this PR. Dynamic plugin Tools don't need those five fields removed from RunCompositionSnapshot — as far as I can tell nothing else in the diff depends on the narrower shape. Landing the feature without the schema change means no migration to write and no P0.

If you'd rather keep it, the repo already has the seam: decodePersistedAgentRunHeader (agent-run.ts:620) is defined by its own comment and tests as the persistence boundary where retired values get folded — automationId and waiting_permission both go through it — while decodeAgentRunHeader stays strict about the current shape. A v1→v2 fold there (drop the removed fields, set schemaVersion: 2) is a few lines, and AGENT_RUN_CONTINUATION_SOURCE_V1_SHAPE/V2_SHAPE in the same file is the existing precedent for discriminated decoding.

Worth flagging either way: the only test that guarded this now asserts the new behaviour. sqlite-core-execution-store.test.ts:733's fixture was updated from schemaVersion: 1 to 2, so no test in the tree constructs a v1 record any more, and run-composition.test.ts asserts that v1-shaped input must throw.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. RunComposition is restored to the exact persisted v1 shape and semantics; the SQLite fixture is back on v1 and the decoder regression now explicitly accepts v1 and rejects v2. Dynamic request surfaces remain separate RequestComposition records, so existing databases require no migration.

const tools = [...selectedTools];
assertUniqueToolNames(tools);
const resolveTools = (): readonly MakaTool[] => {
const additionalTools = input.boundTools ? [] : (input.resolveAdditionalTools?.() ?? []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this guard and the one at :153 are answering different questions, which is how plugin Tools reach toolProfile sessions.

136consthasToolCeiling=input.boundTools!==undefined||input.toolProfile!==undefined;139constadditionalTools=input.boundTools ? [] : (input.resolveAdditionalTools?.()??[]);153constclientCapabilityTools=hasToolCeiling ? [] : (input.clientCapabilities?.tools??[]);

Client capability Tools are excluded from toolProfile sessions; plugin Tools aren't, because :139 only looks at boundTools. I think :139 wants hasToolCeiling too, and that's the whole fix.

What follows from it is why I'm raising it rather than leaving it as a nit. The summary says Host-owned core Tools cannot be shadowed, and PluginToolService does guard that — but only when it's handed the core list, and production calls pluginTools.resolve(sessionId, []) at execution-composition.ts:700, so that guard never fires. Downstream:

  1. additionalTools lands in buildDefaultHostTools(..., [...hostTools, ...additionalTools], ...) at :146, after the builtins.
  2. projectHostedExecutionTools builds new Map(tools.map(tool => [tool.name, tool])) — last wins — so a plugin Bash displaces the Host one.
  3. selected = toolNames.map(name => byName.get(name)) picks the plugin entry.
  4. Then:
tool.name==='Bash'
? { ...tool,description: HEADLESS_CODING_V1_BASH_DESCRIPTION,parameters: HEADLESS_CODING_V1_BASH_PARAMETERS}
: tool

...tool keeps the plugin's impl while description and parameters are overwritten with the Host contract. The model is shown the Host's Bash schema and calls the plugin's implementation against it.

  1. assertUniqueToolNames(resolved) runs after the Map has already collapsed the duplicate, so it can't see the collision.

I graded this P2 rather than higher because Host plugins are trusted and already run arbitrary code in the Host process, so shadowing Read grants no capability they didn't have; uninstalling restores the original, and nothing persisted or externally visible changes. What makes it worth fixing before merge is that it's silent — a plugin author who picks a colliding name gets a schema/implementation mismatch with no diagnostic, and the comment above assertUniqueToolNames describes exactly the invariant that's being missed here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. resolveAdditionalTools now uses the same hasToolCeiling guard as Client Capability Tools, covering both boundTools and toolProfile. Added an interactive composer regression proving an explicit profile excludes plugin additions and preserves exactly one Host Read binding.

Comment threadpackages/runtime/src/agent-run.ts Outdated
if (!this.input.runStore) {
throw new Error('AgentRun store is not configured');
}
if (!this.runStoreAvailable) throw new Error('AgentRun store is unavailable');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this is the same latch check as :554 and :578, but it's the first one in front of provider dispatch.

You followed the file's existing pattern and I don't think that was wrong — recordModelProjectionTransition and recordHistoryCompactCheckpoint both open with this line on main. The difference is what happens when it trips. Those two are recorders whose failure the caller can absorb; recordRequestComposition is awaited before every provider request, so a false latch fails the step itself.

runStoreAvailable is best-effort state: enqueueRunStore:1643 clears it on any trace-append failure, and a busy SQLite is enough. Because the throw happens before enqueueRequiredRunStoreWrite runs, the probe that would lift the latch back never executes, so a single transient hiccup turns into "every remaining step of this Run fails before dispatch" with no self-repair.

recordRunComposition right above shows the shape that avoids this: it goes straight to enqueueRequiredRunStoreWrite, whose comment spells out the reasoning — a successful required write proves the store is available again. Dropping :476 and letting the required-write path do its own probing gets the same durability with a recoverable failure mode.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. The best-effort runStoreAvailable precheck is removed; Request Composition goes through enqueueRequiredRunStoreWrite directly, so a successful required write lifts the latch while dispatch remains fail-closed on a real write failure.

Comment threadpackages/runtime/src/agent-run.ts Outdated
input,
this.requestComposition ? 'change' : 'initial',
);
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — comparing only against the previous epoch means an oscillating surface appends a full replacement every step.

sameRequestCompositionSurface(this.requestComposition, snapshot) looks at the most recent epoch only, so an A→B→A→B sequence writes four full records. activeToolsForRequest does move around within a Turn — repair plans, sandbox boundary finalization and the final child summary step all rewrite it (ai-sdk-backend.ts:2101-2105).

Serializing 40 real Tools from a main build the way the snapshot does gives 44,049 characters per epoch (maka_computer alone is 14 KB, Bash 3.1 KB), before any MCP or plugin Tools. Against EXECUTION_INSPECT_EVIDENCE_MAX_BYTES = 512 * 1024 — shared between AgentRun and runtime events, accumulated by stored_bytes in bounded-evidence.ts:58 — that's roughly 11 epochs before the Run returns limit_exceeded and InspectQueryTooLargeError tells the operator to stop the Host and inspect offline. These events are append-only with no compaction and no cleanup path, and every whole-ledger loader (history-compact-ledger.ts:62, canonical-turn-snapshot.ts:56, conversation-copy.ts:281, …) parses and discards them.

Also worth noting that this.requestComposition is memory-only and never rehydrated from the ledger, so each resume writes a fresh initial epoch even when the surface is identical to what the previous instance recorded. That makes reason less reliable as a ledger fact than it reads — the model-call-attempt.ts:158-159 comment currently suggests every step carries an id, but compaction and memory sub-calls build their own tracker at ai-sdk-backend.ts:3303 and leave requestCompositionId undefined even on a fully upgraded Run.

Deduplicating against every epoch already in this Run — or storing toolSchemas once per (runId, surfaceHash) and having epochs reference it — would keep the DSH-style per-step record without the growth. Since nothing in the tree reads request_composition_resolved or dereferences requestCompositionId outside tests yet, there's also room to store just the hash for now and add the full schemas when a reader needs them.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 714bfc2. Request Composition snapshots are now indexed by a canonical full-surface hash across the entire Run, with existing epochs rehydrated from the ledger. A→B→A and reopening the same Run both reuse the original composition id; ModelCallAttempt references retain the step timeline without repeating full schemas.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Same-Turn install/enable/use is a hard requirement for this PR, so the per-step dynamic source remains. The compatibility follow-up in 714bfc2 keeps RunComposition v1 unchanged, moves all dynamic evidence to immutable logical-step RequestComposition snapshots, preserves retry freezing and fail-closed dispatch, prevents same-name replacement from inheriting an old tool_search activation, and keeps explicit bound/profile ceilings exact. Full workspace lint/typecheck and 348 focused regressions pass.

@xxhZs

xxhZs commented Sep 2, 2026

Copy link
Copy Markdown
ContributorAuthor

Followed up on the two non-blocking composition-bound notes in fadbd38: RequestComposition now applies the same fail-closed 256-entry bound to both toolNames and toolSchemas (and the same 128-character name bound), so exact evidence is never truncated. MCP descriptions are normalized to the persisted 16 KiB bound before becoming provider-visible, while trusted Plugin Tool registration rejects empty or oversized descriptions atomically. Added focused boundary regressions; full lint, all-workspace typecheck, and 253 related tests pass.

@likun666661

Copy link
Copy Markdown
Member

There is a blocking regression in the latest head: the per-step resampling now breaks existing tool_search activation for deferred Tools whose wrappers are reconstructed by the composer.

The new replacement guard in packages/runtime/src/tool-availability.ts:253-258 treats JavaScript object identity as contribution identity:

for(const[name,activatedTool]ofactiveTools){if(this.toolsByName.get(name)!==activatedTool)activeTools.delete(name)}

That is not compatible with the current composer lifecycle. snapshotStepTools() calls resolveTools() before every logical step, and createInteractiveRunComposer.resolveTools() calls buildDefaultHostTools() again. buildDefaultHostTools() reconstructs ordinary, logically unchanged Tool objects on every call, including:

  • todo_read / todo_write via buildSessionTodoTools();
  • AskUserQuestion and the sandbox-boundary Tool;
  • Skill / SkillSearch wrappers;
  • Plan Tools;
  • builtin Tool wrappers.

The resulting sequence is deterministic:

step N
composer builds todo_read object T1
tool_search activates it: activeTools.set("todo_read", T1)
step N+1
composer rebuilds the unchanged todo_read as object T2
ToolAvailabilityRuntime.prepare() sees T2 !== T1
activation is deleted
todo_read is absent from the provider-visible schema set

So tool_search can return activated: ["todo_read"], while the Tool it claims to have activated still does not become visible on the following provider step. That violates the existing tool_search next-step activation contract.

The plugin weather scenario does not disprove this. PluginToolService retains and returns the same frozen exposed object until replacement, so plugin Tools happen to survive the reference comparison. The test in ai-sdk-backend.test.ts also omits toolAvailability, which puts it in full-surface mode and does not exercise the production install -> tool_search -> next-step schema -> invoke path. The new same-name replacement unit test manually supplies stable first and replacement objects and likewise never crosses createInteractiveRunComposer.

Object reference is therefore not a valid general contribution identity. The minimal coherent fix is to keep the base Host binding stable and resample only dynamic plugin contributions, while carrying an explicit activation identity:

static Tool: stable binding identity
plugin Tool: entryId + generation (+ schemaHash if required)

A same-name plugin replacement should invalidate activation because its contribution identity changed. An unchanged static Tool must retain activation even if an implementation currently rebuilds its wrapper.

Please add a production-path regression through createInteractiveRunComposer with toolAvailability enabled:

  1. search for todo_read and prove its schema is visible on the next step;
  2. install a plugin, search for its Tool, and prove it is visible/invocable on the next step;
  3. replace the same-name plugin Tool and prove the new generation requires another search.

The compatibility fixes in 714bfc24a are good, and same-Turn plugin mutation is now an explicit requirement. But this reference-identity change breaks the mechanism the dynamic source is supposed to integrate with. I do not think the PR is safe to merge until this is fixed.

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

Labels

effort/XLOver 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@xxhZs@likun666661@Astro-Han