diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 73e064426a..5b8a925914 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -58,6 +58,10 @@ export interface Config { persistenceRoot?: string /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-core. */ + toolTasks?: NonNullable } ``` @@ -73,7 +77,10 @@ Source: [`packages/ui/acp-agent/src/index.ts:52`](../packages/ui/acp-agent/src/i * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`). + * order), the `tools` object to the tool registry (its presentation `mode`), + * and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle + * owns. Producer opt-in stays producer-local: `toolBash` configures bash only; + * future background-capable tools remain independently composed plugins. * Every field is optional INPUT here because each owner's schema * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the * schema is the INTERSECTION of the owners' own schemas (the registry's @@ -91,6 +98,10 @@ export interface Config { tools?: ToolsConfig /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig + /** Model-facing bash tool config, including this producer's background opt-in. */ + toolBash?: toolBash.Config + /** Generic background-task control-tool wait bounds. */ + toolTasks?: toolTasks.Config } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ @@ -104,9 +115,9 @@ export interface SkillConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) -Source: [`packages/core/agent-core/src/index.ts:87`](../packages/core/agent-core/src/index.ts) +Source: [`packages/core/agent-core/src/index.ts:90`](../packages/core/agent-core/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -631,6 +642,10 @@ export interface Config { welcome?: string /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-core. */ + toolTasks?: NonNullable /** * If set, the `main` agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cea4ec626f..0178a95a08 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -271,7 +271,7 @@ attachSurface(name: string): () => void Types: [Agent](../core-data-structures/core.md) -Source: [`packages/tasks/tasks/src/index.ts:95`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:96`](../../packages/tasks/tasks/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 10e1d7f6d2..b0cab68522 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -52,7 +52,9 @@ interface TaskHooks { * released the task's resources (process exited, child agent disposed) — * not merely when the work finished. Must never reject; a rejection is * contained as a `failed` outcome and logged as a producer contract - * violation. + * violation. If `cancel` throws during teardown, the runtime may force-fail + * only its registry record to avoid deadlock because this promise may never + * settle; that fallback explicitly does not claim work quiescence. */ done: Promise /** @@ -135,4 +137,4 @@ interface TaskRead { ## The service -`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per settlement, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and awaited when their owning agent disposes (the `ctx.agents.onCleanup` seam); the model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md). +`TaskService` (`ctx.tasks` — [`packages/tasks/tasks/src/index.ts`](../../packages/tasks/tasks/src/index.ts)): `start` (preflight → producer `run()` → atomic commit, fenced by `attachSurface`), non-consuming `get`/`list` (caller-scoped — owned-by-caller plus unowned only), `read` (consuming for stream kinds), `kill` (producer `cancel` first; a throw leaves the task untouched), `wait` (bounded, abort cancels the wait only), and `onTaskDone` (a `TaskDoneListener` per terminal record, effect-scoped, contained). Every read/kill/wait/get compares the task's owner session with the caller's and rejects a foreign one. Owned tasks are cancelled and normally awaited to producer quiescence when their owning agent disposes (the `ctx.agents.onCleanup` seam); a teardown cancel that throws force-fails only the registry record and reports that the underlying work may be orphaned, preventing disposal deadlock without claiming quiescence. The model-facing surface over all of this is [dsh-tool-tasks](../../packages/tasks/tool-tasks/README.md). diff --git a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 39f2e70dae..137f6cd6ae 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/docs/rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -59,9 +59,9 @@ interface TaskOutcome { The task status vocabulary is generic and closed: `running`, `stopping` (cancel requested, not yet settled), and the three terminal values above. Kind-specific meaning rides in `detail`, so the registry never learns process or agent semantics — the method presence (`readOutput`) is the capability, mirroring `SubagentRun.sendMessage`. -The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — is what makes owner cleanup and service disposal awaitable without a second completion surface; this resolves the old seam's duplication of a per-task `done` promise AND a global `onTaskDone` registry by making the promise the producer contract and the listener registry the consumer surface. +The registry attaches ONE continuation to `done`: record the terminal snapshot, then notify task-done listeners with per-listener containment (the guarantee the bash seam's `notifyTaskDone` used to give its own listener set). `done` settling at quiescence — not merely at completion — makes ordinary owner cleanup and service disposal awaitable without a second completion surface. Settlement is first-wins because teardown has one explicit failure fallback: if producer `cancel` throws before the request is delivered, `done` may never settle, so the registry force-fails the record with a possible-orphan detail instead of deadlocking disposal; a late producer outcome cannot overwrite that diagnosis or notify twice. This fallback terminates bookkeeping, not necessarily the underlying work. -Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits settlement — no orphans survive `fiber.dispose()`. +Registrations are NOT effect-scoped to the registering fiber: a task belongs to its owning agent and its producing backend, not to the tool plugin whose call started it, so an HMR reload of `dsh-tool-bash` or `dsh-tool-tasks` never orphans or kills a running task (the same argument that used to keep bash ownership in the executor). The registry's own disposal cancels every live task and awaits contract-compliant producers to quiescence. A teardown cancel that throws force-fails the terminal record and logs that work may be orphaned; this prevents a broken producer from deadlocking the fiber without pretending the work stopped. ## Authorization and the service surface @@ -100,16 +100,16 @@ Completion notices stay durable context, not a wake-up (`agent.inject()` appends ## Producer opt-in and schema exposure -Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A disabled producer omits the parameter from its schema entirely — and, because the arg validator deliberately allows undeclared keys, its `execute` ALSO refuses a forced `run_in_background: true` loud (the omission is advertising; the execution-time check is the enforcement). `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation. +Whether a producer tool offers `run_in_background` is that producer's own defaulted config: `enableRunInBackground?: boolean` on `dsh-tool-bash` and on each `dsh-tool-subagent` instance (both default `true` — bash keeps its always-exposed behavior, and a deployment disables either per instance from cordis.yml, no code edit). A bundle forwards the configs of the child plugins it owns: `dsh-agent-core` exposes `toolBash` for its built-in producer and `toolTasks` for the generic control surface, while independently composed producers such as subagent instances receive config directly. This forwarding is config reachability, not producer registration: future background-capable tools do not become `agent-core` fields unless that bundle also chooses to own them. A disabled producer omits the parameter from its schema entirely — and, because the arg validator deliberately allows undeclared keys, its `execute` ALSO refuses a forced `run_in_background: true` loud (the omission is advertising; the execution-time check is the enforcement). `ctx.tasks` plays no part in schema shaping — it never rewrites or decorates a producer's tool schema (Kimi Code regex-rewrites its bash description when background is disabled; config-owns-the-schema makes that trick unnecessary) — it only provides runtime registration. The two halves compose fail-loud: the producer's config decides what the model sees, and a background call that still reaches `start()` without a control surface throws the load-this-package error. `start()` preflights every failable check (the fence, validation, the owner-cleanup attach) BEFORE invoking the producer's `run()` and commits atomically after — background work started without a collectable id is structurally impossible, not a producer rollback obligation. ## The awaited owner-cleanup seam -A background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is emitted synchronously inside the disposal chain without awaiting listener work, so an emit listener cannot promise quiescence (the analysis in [the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)). The runtime therefore needs a seam the owning agent's disposal chain actually awaits, and that seam belongs to `dsh-agent`, where every lifecycle consumer can reach it: +A contract-compliant background task must not outlive its owner: the subagent case leaks live child agents/sessions otherwise, and `agent/disposed` is emitted synchronously inside the disposal chain without awaiting listener work, so an emit listener cannot promise quiescence (the analysis in [the feature RFC](../feature/2026-07-08-background-subagent-tasks.md)). The runtime therefore needs a seam the owning agent's disposal chain actually awaits, and that seam belongs to `dsh-agent`, where every lifecycle consumer can reach it: - `AgentRegistry.onCleanup(agentId, cleanup: () => Promise): () => void` — a per-agent cleanup registry (registrations are effects; the disposer unregisters). - The loop's composite disposal chain carries one link for it: after stop-and-drain and before unregister, `await ctx.agents.drainCleanups(agent.id)` runs every registered cleanup with per-cleanup containment (a throwing cleanup is logged and never starves later cleanups or the rest of the chain). This is a documented `dsh-agent-loop` change; running cleanups is part of the `AgentFactory` dispose contract so a replacement loop honors it too. -`dsh-tasks` consumes the seam: the first task registered for an owner attaches one cleanup that cancels the owner's still-live tasks, awaits each task's `done` (quiescence), and drops the owner's snapshots. `AgentHandle.dispose()` thus resolves only after the owner's background children are actually gone, and the guarantee composes transitively: a background subagent that started background tasks of its own drains them when its child agent disposes inside the parent task's settlement path (the cascade OpenCode implements with explicit parent-chain walking falls out of the seam here). This is a deliberate behavior change for bash — a background bash task used to outlive its owning agent until service disposal — adopted for uniformity: an ownerless task is the sanctioned way to outlive an agent, and a future durable-job RFC is the way to outlive the runtime. +`dsh-tasks` consumes the seam: the first task registered for an owner attaches one cleanup that cancels the owner's still-live tasks, normally awaits each task's `done` (quiescence), and drops the owner's snapshots. For contract-compliant producers, `AgentHandle.dispose()` therefore resolves only after the owner's background children are actually gone, and the guarantee composes transitively: a background subagent that started background tasks of its own drains them when its child agent disposes inside the parent task's settlement path (the cascade OpenCode implements with explicit parent-chain walking falls out of the seam here). A producer whose teardown cancel throws is the explicit degradation: its record settles `failed` with a possible-orphan detail and cleanup continues. An ownerless task is the sanctioned way for healthy work to outlive an agent, and a future durable-job RFC is the way to outlive the runtime. ## Bash migration @@ -169,7 +169,7 @@ Everything model-visible already lands in the log: starts and reads are tool cal ## Testing -Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a failed producer `cancel` leaves the task untouched — disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration), both producers' start mapping plus the structural no-orphan guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture. +Unit coverage pins the registry lifecycle (register/read/kill/wait/list, owner isolation including no-agent callers, stream-vs-final read semantics, listener containment, notice suppression after an explicit kill or terminal read/wait, the `attachSurface` fence, start atomicity — a failed preflight mutates nothing and burns no counter; a model-facing failed `cancel` leaves the task untouched; a teardown failed `cancel` force-fails the record once without awaiting `done` — ordinary disposal quiescence, per-kind id counters), the `onCleanup` drain ordering + containment (including mid-drain registration and effect self-release), both producers' start mapping plus the structural no-uncollectable-work guarantee (a failed preflight means the producer's `run()` — the spawn — was never invoked), and unchanged foreground bash/subagent behavior. Snapshot coverage pins the task tool schemas and the prompt section through the pinned-header fixture. ## Consequences @@ -177,4 +177,6 @@ One background-task contract exists instead of a per-capability clone family: a Owner-scoped cleanup changed bash semantics: a task that previously outlived its agent now dies with it. Deployments that relied on fire-and-forget background commands start them unowned (a non-agent caller) or accept the new lifecycle; the uniformity was judged worth the change, and the durable-job direction remains open for real survival requirements. +Teardown trusts the producer contract that a successful `cancel` leads to `done` settling at quiescence. An explicit cancel throw is detectable and force-fails the record with a possible-orphan warning so disposal cannot deadlock; a cancel that returns but silently fails is indistinguishable from a slow stop and can still stall teardown. Covering that residual requires a bounded lifetime or a separate forced-disposal contract, both outside this runtime shape. + `wait` is the first blocking tool call whose duration is model-controlled; the config cap bounds it, but a model that serializes on `wait` loses the parallelism the feature exists for — prompt guidance mitigates, and a future continuation-policy guard can enforce. Recording live background-flow snapshot scenarios (a polled and killed background command, a completion-notice turn) and a with-key e2e background lifecycle require a `DEEPSEEK_API_KEY` re-record and remain named follow-up work. The runtime deliberately defers durable/cross-restart tasks, non-consuming observation cursors, and foreground→background promotion (see Alternatives). diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 351ae1fdf4..44909ed19c 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -41,11 +41,12 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas, +// { agents?, persona?, toolOrder?, tools?, skills?, toolBash?, toolTasks? } +// The schema intersects the owner schemas, // so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 64e1eaa2f8..4b3a41e98e 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -77,7 +77,10 @@ export interface SkillConfig { * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`). + * order), the `tools` object to the tool registry (its presentation `mode`), + * and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle + * owns. Producer opt-in stays producer-local: `toolBash` configures bash only; + * future background-capable tools remain independently composed plugins. * Every field is optional INPUT here because each owner's schema * supplies the default (`[]` / `''` / absent — lexicographic / `native`); the * schema is the INTERSECTION of the owners' own schemas (the registry's @@ -95,6 +98,10 @@ export interface Config { tools?: ToolsConfig /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig + /** Model-facing bash tool config, including this producer's background opt-in. */ + toolBash?: toolBash.Config + /** Generic background-task control-tool wait bounds. */ + toolTasks?: toolTasks.Config } /** The skill config schema exported for app packages that forward `skills`. */ @@ -104,11 +111,22 @@ export const SkillConfigSchema: z = z.object({ tool: toolSkill.Config, }) +/** The bash-tool config schema exported for app packages that forward `toolBash`. */ +export const ToolBashConfigSchema: z = toolBash.Config + +/** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ +export const ToolTasksConfigSchema: z = toolTasks.Config + /** Intersect the owners' schemas so validation + defaulting stay identical. */ export const Config = z.intersect([ AgentLoop.Config, SystemPrompt.Config, - z.object({ tools: ToolRegistry.Config, skills: SkillConfigSchema }), + z.object({ + tools: ToolRegistry.Config, + skills: SkillConfigSchema, + toolBash: ToolBashConfigSchema, + toolTasks: ToolTasksConfigSchema, + }), ]) as unknown as z /** @@ -140,8 +158,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(AgentRegistry) ctx.plugin(TaskService) ctx.plugin(invariants) - ctx.plugin(toolBash) + ctx.plugin(toolBash, config.toolBash ?? {}) ctx.plugin(toolSkill, config.skills?.tool ?? {}) - ctx.plugin(toolTasks) + ctx.plugin(toolTasks, config.toolTasks ?? {}) ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) } diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 1803a5e3dd..e9bc584418 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { AgentId } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' +import { CallId, type Message } from '@deepseek-ai/dsh-llm' async function composePrefix(ctx: Context, cwd: string): Promise { const empty: Message[] = [] @@ -27,12 +27,13 @@ async function composePrefix(ctx: Context, cwd: string): Promise { * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless * bin smokes; here we assert the composition + config forwarding. */ -async function mount(config?: agentCore.Config): Promise { +async function mount(config?: agentCore.Config, withBash = false): Promise { const oldDshHome = process.env.DSH_HOME const oldAgentsHome = process.env.DSH_AGENTS_HOME process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-')) process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-')) const ctx = new Context() + if (withBash) ctx.provide('bash', { sandboxMode: undefined }) try { await ctx.plugin(agentCore, config) // The bundle mounts its children inside apply() (not awaited there); let their @@ -153,6 +154,33 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) + it('forwards its bundled tool configs to tool-bash and tool-tasks', async () => { + const ctx = await mount({ + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + }, true) + + const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') + expect(bash).toBeDefined() + expect(Object.keys((bash!.parameters as { properties: Record }).properties)) + .not.toContain('run_in_background') + + const id = ctx.tasks.start({ + kind: 'probe', + label: 'config forwarding probe', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + }) + const wait = vi.spyOn(ctx.tasks, 'wait') + await ctx.tools.execute({ + callId: CallId('task-config-forwarding'), + name: 'task_output', + arguments: { task_id: id, wait: true }, + }) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + + await ctx.fiber.dispose() + }) + it('uses the default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index df2f21ee91..3cfab12f45 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -9,7 +9,7 @@ The background task registry (`ctx.tasks`): a runtime-global, CONCRETE service ( - `read(id, caller?): TaskRead` — stream kinds consume the per-task cursor (v1's single intended reader is the owning model — a non-consuming multi-reader surface would be a cursor/snapshot API extension, not a `read` change); final kinds read the terminal output idempotently. - `kill(id, caller?, reason?)` — `'requested'` (live task: producer `cancel` runs first — a throw fails the kill loud and leaves the task untouched — then `stopping`) or `'already-terminal'`. Every successful kill marks the task `reported` (the killer saw the end → completion notice suppressed). - `wait(id, timeoutMs, caller?, signal?)` — resolves with the terminal snapshot (marked `reported`), or the live snapshot at timeout; an aborted signal rejects the WAIT only — unless the task already settled, in which case the wait still resolves and delivers the terminal snapshot (settlement suppressed the completion notice on this waiter's behalf, so rejecting would leave the finish both unreported and un-noticed). Timing is a [`dsh-timeout`](../../util/timeout/README.md) `deadline()` scoped to the `TASK_WAIT_TIMEOUT` code, so a nested foreign deadline never misreads as a wait timeout. -- `onTaskDone(listener)` — exactly once per task with the terminal snapshot; effect-scoped, per-listener containment, silent after service disposal. +- `onTaskDone(listener)` — exactly once per terminal task record; effect-scoped, per-listener containment, silent after service disposal. - `attachSurface(name)` — declares a control surface exists (the model tools, or a deployment's custom surface); effect-scoped. Every read/kill/wait/get compares the task's owner session (`owner.session.header.id`) with the caller's and rejects a foreign one — ids are predictable (`bash-1`), so the fence, not id secrecy, is the isolation boundary. @@ -17,8 +17,9 @@ Every read/kill/wait/get compares the task's owner session (`owner.session.heade ## Lifecycle - Registrations are NOT effect-scoped to the registering fiber: tasks belong to their owning agent + producing backend, so producer/surface HMR reloads never touch them. -- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on the owner's disposal the registry cancels its live tasks, awaits each `done`, and drops the snapshots — `AgentHandle.dispose()` resolves only after quiescence. -- Service disposal closes the listener registry first (late teardown kills stay silent), cancels every live task with containment, and awaits settlement. +- An owned task attaches (once per owner) an awaited cleanup via `ctx.agents.onCleanup`: on owner disposal the registry cancels live tasks, awaits contract-compliant producers to quiescence, and drops their snapshots. If a teardown cancel throws, it force-fails the record and logs that the underlying work may be orphaned rather than deadlocking `AgentHandle.dispose()`. +- Service disposal closes the listener registry first (late teardown settlements stay silent), then applies the same cancellation rule to every live task and awaits terminal records. +- A producer whose `cancel` returns but never causes `done` to settle remains indistinguishable from a slow stop and can stall teardown; solving that residual requires an explicit bounded-lifetime or forced-disposal design. ## Non-goals (v1) diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 9601e296b3..244211fa4f 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -23,8 +23,9 @@ * belongs to its owning agent and producing backend, not to the tool plugin * whose call started it, so an HMR reload of a producer or of the control * surface never orphans or kills a running task. The registry's own disposal - * cancels every live task and awaits settlement — no orphans survive - * `fiber.dispose()`. + * cancels every live task and awaits contract-compliant producers to + * quiescence. If a teardown cancel throws, the registry force-fails its record + * to avoid deadlock and logs that the underlying work may be orphaned. * * @module @deepseek-ai/dsh-tasks */ @@ -77,7 +78,7 @@ interface TrackedTask { reported: boolean /** Resolves once the terminal snapshot is recorded and listeners notified. */ settled: Promise - /** Resolver for {@link settled} (called exactly once, by {@link TaskService.settle}). */ + /** Resolver for {@link settled} (called by the first effective {@link TaskService.settle}). */ markSettled: () => void /** Live {@link TaskService.wait} calls — a settlement with waiters marks the task reported. */ waiters: number @@ -98,8 +99,8 @@ export class TaskService extends Service { private surfaces = new Set() private listeners = new Set() private listenersClosed = false - /** Owner agents that already have this registry's cleanup attached. */ - private ownerCleanups = new Set() + /** Owner agents whose cleanup effect is attached, mapped to its self-detacher. */ + private ownerCleanups = new Map void>() /** * The service's OWN construction-time context, for work that outlives the * calling fiber: detached settlement continuations (logging), and the @@ -338,8 +339,8 @@ export class TaskService extends Service { } /** - * Register a completion listener, called exactly once per task with the - * terminal snapshot. Effect-scoped (disposed with the calling fiber); + * Register a completion listener, called exactly once per terminal task + * record with its snapshot. Effect-scoped (disposed with the calling fiber); * per-listener containment (one throwing listener is logged, never starves * the rest); never fires after this service is disposed. * @param listener - called with each settling task's terminal snapshot. @@ -409,13 +410,17 @@ export class TaskService extends Service { } /** - * Record a task's terminal outcome (called exactly once — the single `done` - * continuation is the only caller), notify listeners with containment, then - * release waiters. A settlement observed by a pending {@link wait} marks - * the task reported BEFORE listeners run, so the notice surface can + * Record the first terminal outcome, notify listeners with containment, then + * release waiters. Normally the producer's single `done` continuation calls + * this; teardown also force-fails the record when `cancel` throws and `done` + * may never settle. First-wins makes a producer outcome arriving after that + * fallback a no-op, so listeners fire once and the diagnosed terminal state + * is never overwritten. A settlement observed by a pending {@link wait} + * marks the task reported BEFORE listeners run, so the notice surface can * suppress its redundant "finished". */ private settle(task: TrackedTask, outcome: TaskOutcome): void { + if (isTerminal(task.status)) return task.status = outcome.status task.detail = outcome.detail task.output = outcome.output @@ -439,27 +444,38 @@ export class TaskService extends Service { * the agent's disposal chain drains (`ctx.agents.drainCleanups`), the * owner's still-live tasks are cancelled, awaited to settlement, and their * snapshots dropped. Registered through {@link selfCtx} so the cleanup - * survives producer-plugin reloads. Fails loud when no agent registry is - * mounted — an owned background task without the cleanup seam would outlive - * its owner silently. + * survives producer-plugin reloads. When the cleanup starts, it detaches its + * own effect before awaiting task settlement, so completed owners do not + * accumulate effect wrappers (and captured sessions) on the long-lived tasks + * fiber. A narrow race remains if new work starts on an agent already being + * drained: before this callback clears the owner entry, that start can reuse + * the in-flight cleanup after its task snapshot was taken. + * Fails loud when no agent registry is mounted — an owned background task + * without the cleanup seam would outlive its owner silently. */ private ensureOwnerCleanup(owner: Agent): void { - if (this.ownerCleanups.has(owner.id)) return + const ownerId = owner.id + if (this.ownerCleanups.has(ownerId)) return const agents = this.selfCtx.get('agents') if (agents === undefined) { throw new Error('background task ownership requires the agent registry (load @deepseek-ai/dsh-agent)') } + const ownerSession = owner.session.header.id // Attach FIRST, record after: onCleanup throws for an unregistered agent, // and marking the owner as covered before that would make every later // registration for the same owner silently skip the cleanup. - agents.onCleanup(owner.id, async () => { - this.ownerCleanups.delete(owner.id) - await this.disposeOwned(owner.session.header.id) + const detach = agents.onCleanup(ownerId, async () => { + const disposeEffect = this.ownerCleanups.get(ownerId) + this.ownerCleanups.delete(ownerId) + // A drain racing lifecycle teardown may find that the effect was already + // detached; otherwise this removes its wrapper from the tasks fiber now. + disposeEffect?.() + await this.disposeOwned(ownerSession) }) - this.ownerCleanups.add(owner.id) + this.ownerCleanups.set(ownerId, detach) } - /** Cancel (contained), await, and drop every task owned by one session. */ + /** Cancel, await terminal records, and drop every task owned by one session. */ private async disposeOwned(ownerSession: string): Promise { const owned = [...this.store.values()].filter(task => task.ownerSession === ownerSession) this.cancelForTeardown(owned, 'owner disposed') @@ -469,8 +485,10 @@ export class TaskService extends Service { /** * Service teardown: close the listener registry FIRST (late completions - * from teardown kills stay silent), cancel every live task, and await - * quiescence. No orphan child work survives the tasks fiber. + * from teardown kills stay silent), cancel every live task, and await each + * terminal record. Contract-compliant producers settle at quiescence; a + * producer whose cancel throws is force-failed so disposal cannot deadlock, + * with the possible underlying orphan logged explicitly. */ private async disposeAll(): Promise { this.listenersClosed = true @@ -483,18 +501,25 @@ export class TaskService extends Service { /** * Teardown-path cancellation with per-task containment: unlike the - * model-facing {@link kill} (where a throwing producer `cancel` should fail - * the tool call loudly), a teardown must reach quiescence past a broken - * producer, so a throw is logged and the sweep continues. + * model-facing {@link kill} (where a throwing producer `cancel` fails the tool + * call and leaves the record live), teardown force-fails a record whose cancel + * throws because its `done` may depend on a request that never arrived. This + * prevents disposal deadlock but cannot prove the underlying work stopped, so + * the potential orphan is carried in the detail and warning. A cancel that + * returns but never leads to `done` remains indistinguishable from a slow stop + * and can still stall teardown; fixing that requires a separate bounded-lifetime + * or forced-disposal design. */ private cancelForTeardown(tasks: TrackedTask[], reason: string): void { for (const task of tasks) { if (isTerminal(task.status)) continue - task.status = 'stopping' try { task.cancel(reason) + task.status = 'stopping' } catch (error: unknown) { - this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown: ${String(error)}`) + const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` + this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) + this.settle(task, { status: 'failed', detail }) } } } diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index c03c8a2584..b4337cc1ff 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -105,7 +105,9 @@ export interface TaskHooks { * released the task's resources (process exited, child agent disposed) — * not merely when the work finished. Must never reject; a rejection is * contained as a `failed` outcome and logged as a producer contract - * violation. + * violation. If `cancel` throws during teardown, the runtime may force-fail + * only its registry record to avoid deadlock because this promise may never + * settle; that fallback explicitly does not claim work quiescence. */ done: Promise /** diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index a2324448cf..11cf9ff8b0 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -444,11 +444,46 @@ describe('TaskService owner cleanup', () => { expect(ctx.tasks.list(owner)).toEqual([]) }) - it('contains a throwing producer cancel on the cleanup path', async () => { + it('releases the owner-cleanup effect from the tasks fiber after its drain', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const tasksFiber = await ctx.plugin(TaskService) + ctx.tasks.attachSurface('test-surface') + const owner = stubAgent('owner') + ctx.agents.register(owner) + const ownerCleanupEffects = () => tasksFiber.getEffects() + .filter(effect => effect.label === 'agents.onCleanup()') + + const first = producer({ owner }) + ctx.tasks.start(first.spec) + expect(ownerCleanupEffects()).toHaveLength(1) + first.settle({ status: 'completed' }) + await tick() + await ctx.agents.drainCleanups(owner.id) + + // Only the owner registration is released; the long-lived tasks service + // and its own teardown effect remain active. + expect(ownerCleanupEffects()).toHaveLength(0) + expect(ctx.get('tasks')).toBeDefined() + expect(tasksFiber.getEffects().some(effect => effect.label === 'tasks teardown')).toBe(true) + + // The same still-live owner can attach and release a fresh registration. + const second = producer({ owner }) + ctx.tasks.start(second.spec) + expect(ownerCleanupEffects()).toHaveLength(1) + second.settle({ status: 'completed' }) + await tick() + await ctx.agents.drainCleanups(owner.id) + expect(ownerCleanupEffects()).toHaveLength(0) + }) + + it('force-fails a throwing teardown cancel without awaiting producer done, first outcome wins', async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const owner = stubAgent('owner') ctx.agents.register(owner) + const seen: TaskSnapshot[] = [] + ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot)) let settle!: (outcome: TaskOutcome) => void ctx.tasks.start({ @@ -462,9 +497,27 @@ describe('TaskService owner cleanup', () => { }) const drain = ctx.agents.drainCleanups(owner.id) - settle({ status: 'failed', detail: 'gave up' }) - await drain - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancel boom')) + let drained = false + void drain.then(() => { drained = true }) + await tick() + const drainedWithoutProducerDone = drained + if (!drainedWithoutProducerDone) { + // Failure-path cleanup for the pre-fix implementation: let its pending + // drain finish without weakening the assertion captured above. + settle({ status: 'completed' }) + await drain + } else { + // A late producer completion must not replace the forced failed record or + // notify listeners a second time. + settle({ status: 'completed' }) + await tick() + } + + expect(drainedWithoutProducerDone).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) + expect(seen).toHaveLength(1) + expect(seen[0]?.status).toBe('failed') + expect(seen[0]?.detail).toContain('cancel threw during teardown') expect(ctx.tasks.list(owner)).toEqual([]) }) }) @@ -498,6 +551,44 @@ describe('TaskService disposal', () => { expect(seen).toEqual([]) }) + it('force-fails a throwing cancel so service disposal does not await producer done', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(TaskService) + ctx.tasks.attachSurface('test-surface') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: TaskSnapshot[] = [] + ctx.tasks.onTaskDone(snapshot => void seen.push(snapshot)) + + let settle!: (outcome: TaskOutcome) => void + ctx.tasks.start({ + kind: 'bash', + label: 'broken service task', + run: () => ({ + cancel() { throw new Error('service cancel boom') }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + + const disposal = fiber.dispose() + let disposed = false + void disposal.then(() => { disposed = true }) + await tick() + const disposedWithoutProducerDone = disposed + if (!disposedWithoutProducerDone) { + // Failure-path cleanup for the pre-fix implementation. + settle({ status: 'completed' }) + await disposal + } else { + settle({ status: 'completed' }) + await tick() + } + + expect(disposedWithoutProducerDone).toBe(true) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned')) + expect(seen).toEqual([]) + }) + it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() await ctx.plugin(TaskService) diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index d81c9cc091..856dbfd217 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -27,6 +27,10 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `model` | (required) | the per-session agent template the bridge creates agents from | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` | +| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` | +| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` | +| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor (`bash-local`). diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 27b919f756..4fc867eda6 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -62,6 +62,10 @@ export interface Config { persistenceRoot?: string /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-core. */ + toolTasks?: NonNullable } export const Config: z = z.object({ @@ -74,6 +78,8 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, persistenceRoot: z.string().default('./.sessions'), skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: agentCore.ToolTasksConfigSchema, }) /** @@ -89,6 +95,8 @@ export function apply(ctx: Context, config: Config): void { ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, ...config.skills !== undefined ? { skills: config.skills } : {}, + ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, + ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, }) ctx.plugin(UserInteractionService) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index a9b207bfb7..e396d33d4d 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -18,8 +18,9 @@ import * as acpAgent from '../src/index.ts' * ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`; * this spec asserts the composition and the persistenceRoot default branch. */ -async function mount(config: acpAgent.Config): Promise { +async function mount(config: acpAgent.Config, withBash = false): Promise { const ctx = new Context() + if (withBash) ctx.provide('bash', { sandboxMode: undefined }) await ctx.plugin(acpAgent, config) // The bundle mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services are ready. @@ -110,6 +111,19 @@ describe('dsh-acp-agent composition', () => { await ctx.fiber.dispose() }) + it('forwards bundled tool config into agent-core', async () => { + const ctx = await mount({ + model: 'mock', + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + skills: await isolatedSkillsConfig(), + }, true) + const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') + expect(Object.keys((bash!.parameters as { properties: Record }).properties)) + .not.toContain('run_in_background') + await ctx.fiber.dispose() + }) + it('exposes its plugin shape', () => { expect(acpAgent.name).toBe('acp-agent') expect(acpAgent.Config).toBeDefined() diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..45bc6847df 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -28,6 +28,10 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `model` | (required) | the pre-created `main` agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `agent-core` | +| `skills` | owner defaults | skill registry, local provider, and model-facing skill-tool config through `agent-core` | +| `toolBash` | owner defaults | model-facing bash config through `agent-core`, including bash's producer-local `enableRunInBackground` | +| `toolTasks` | owner defaults | generic `task_output` wait bounds through `agent-core` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 1037c28112..babdb41690 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -77,6 +77,10 @@ export interface Config { welcome?: string /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-core. */ + toolTasks?: NonNullable /** * If set, the `main` agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` @@ -96,6 +100,8 @@ export const Config: z = z.object({ persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: agentCore.ToolTasksConfigSchema, resumeSessionId: z.string(), }) @@ -119,6 +125,8 @@ export function apply(ctx: Context, config: Config): void { ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], ...config.skills !== undefined ? { skills: config.skills } : {}, + ...config.toolBash !== undefined ? { toolBash: config.toolBash } : {}, + ...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {}, }) ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index cbd54b39b6..34a3268b3a 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -25,8 +25,9 @@ import * as stdioAgent from '../src/index.ts' * stray default rather than crash). Here we assert the composition + config * forwarding the unit tier can reach. */ -async function mount(config: stdioAgent.Config): Promise { +async function mount(config: stdioAgent.Config, withBash = false): Promise { const ctx = new Context() + if (withBash) ctx.provide('bash', { sandboxMode: undefined }) await ctx.plugin(stdioAgent, config) // The app mounts its children inside apply() (not awaited there); let their // fibers settle so the spine services + the pre-created agent are ready. @@ -135,6 +136,19 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) + it('forwards bundled tool config into agent-core', async () => { + const ctx = await mount({ + model: 'mock', + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + skills: await isolatedSkillsConfig(), + }, true) + const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') + expect(Object.keys((bash!.parameters as { properties: Record }).properties)) + .not.toContain('run_in_background') + await ctx.fiber.dispose() + }) + it('exposes its name and Config schema', () => { expect(stdioAgent.name).toBe('stdio-agent') expect(stdioAgent.Config).toBeDefined()