Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-14 22:17:50 +08:00
672 changed files with 10295 additions and 14200 deletions

View File

@@ -21,3 +21,23 @@ Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, perso
| Key | Meaning |
|---|---|
| `providerName` | Registry name on `ctx.subagents` (default `fork`). |
See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared.
## Model Experience
### Child-agent history and envelope
**What the model sees**: The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded.
**Token effect**: Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history.
### Parent tool result, indirectly
**What the model sees**: The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work.
**Token effect**: Parent input grows by one data-dependent final result retained until compaction.
## Known Limitations and Deferred Work
- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs.
- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing.

View File

@@ -1,22 +1,9 @@
/**
* The in-process FORK subagent backend: registers a {@link SubagentProvider} on
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a
* prefix of the parent's session log — so the child inherits the parent's
* conversation context instead of starting fresh. The run mechanics live in
* `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this
* backend just computes the seed. The spawn backend is an independent peer over
* the same driver.
*
* The seed boundary is the crux: at the moment a subagent tool's `execute`
* runs, the parent's CURRENT turn is open and unbalanced (it holds the
* `assistant/message` with this spawn's tool-call, plus the dangling `tool/call`
* with no `tool/result`). Seeding that raw prefix gives the child an open turn
* the session constructor and the dev-mode invariants replay REJECT. So the
* fork seeds only the **balanced completed-turn prefix**: the parent's log up
* to and including its last `turn/end`, excluding the in-flight turn entirely.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default.
*
* `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the
* parent's session log — so the child inherits the parent's conversation context instead of
* starting fresh. The seed ends at the last `turn/end`: the current tool-call turn is
* unbalanced and cannot be replayed as a valid child session.
* @module @deepseek-ai/dsh-subagent-fork
*/
@@ -45,12 +32,10 @@ export const Config: z<Config> = z.object({
})
/**
* The balanced completed-turn prefix of `parent`'s log: every event up to and
* including the last `turn/end`. Empty if the parent has never completed a turn
* (the in-flight turn is excluded, so a parent on its very first turn forks an
* empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
* log keeps `seq === index`), so it is a valid session seed; the in-flight,
* unbalanced turn is dropped so the invariants replay accepts it.
* The balanced completed-turn prefix of `parent`'s log: every event up to and including the
* last `turn/end`. The in-flight turn is excluded; before any completed turn the child starts
* fresh. Because live sequence numbers equal array indexes, the result remains a valid seed
* beginning at sequence zero.
* @param parent - the agent whose session log to slice.
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
*/

View File

@@ -135,10 +135,9 @@ describe('dsh-subagent-fork', () => {
})
it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => {
// Drive the parent so it has ONE completed turn, then start a SECOND turn
// that is still open (a hanging model call), and fork while it's in flight.
// The fork must seed only the completed first turn — an unbalanced seed
// would make the invariants replay throw inside ctx.subagents.start.
// Drive the parent so it has one completed turn, then start a SECOND turn that is still
// open (a hanging model call), and fork while it's in flight. The seed must stop after the
// balanced first turn; including the open turn would fail invariant replay during start.
const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
parent.send([{ type: 'text', text: 'q1' }])
await parent.whenIdle()
@@ -183,12 +182,8 @@ describe('dsh-subagent-fork', () => {
})
it('does NOT return the seeded parent output when the child produces no message of its own', async () => {
// Regression: readResult must scope to the child's OWN events (after the
// seed). The parent completes a turn with a distinctive assistant message,
// then the fork child's own turn finishes with a bare `stop` and NO
// assistant/message. Scanning the whole (seeded) log would return the
// parent's "parent stale" message with stopReason 'completed'; scoped to the
// child's own events the output is empty.
// `readResult` must scan only child-owned events after the seed. The child emits no assistant
// message, so scanning the whole log would incorrectly return the parent's distinctive text.
const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
parent.send([{ type: 'text', text: 'parent question' }])
await parent.whenIdle()