From caf32c33e3119f8060c91b35bd13923895f75cd4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 19 Oct 2026 19:10:10 -0600 Subject: [PATCH] feat(opencode): declare toolCallSpans on /v1/capabilities so agent-runtime can bound a silent model call #266 made opencode report each tool call at its start; its finished part already carried the outcome. agent-runtime (#1885) runs modelSilenceMs only on a route that declares toolCallSpans: true, and no backend declared it, so the bound was off on every route. The opencode backend now declares it and /v1/capabilities serves it. --- src/backends/opencode.ts | 3 +++ src/backends/types.ts | 6 ++++++ src/sessions/retained.ts | 9 ++++++--- tests/retained-sessions.test.ts | 8 ++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/backends/opencode.ts b/src/backends/opencode.ts index ecbc5c4..2386fbf 100644 --- a/src/backends/opencode.ts +++ b/src/backends/opencode.ts @@ -57,6 +57,9 @@ export interface OpencodeBackendOptions { export class OpencodeBackend implements Backend { readonly name = 'opencode' readonly defaultExecutionTimeoutMs: number + // The signal plugin reports a call at its start; it is installed in the turn's private config + // directory, which every turn with an AgentProfile has (agent-runtime always sends one). + readonly toolCallSpans = true private readonly spawner: Spawner constructor(private readonly opts: OpencodeBackendOptions) { this.defaultExecutionTimeoutMs = opts.timeoutMs diff --git a/src/backends/types.ts b/src/backends/types.ts index 87797df..17bed9d 100644 --- a/src/backends/types.ts +++ b/src/backends/types.ts @@ -603,6 +603,12 @@ export interface Backend { readonly name: string /** Operator-selected fallback used only when `execution.timeoutMs` is absent. Zero/absent means no deadline. */ readonly defaultExecutionTimeoutMs?: number + /** + * The stream reports each tool call when it starts and its outcome when it ends, so a caller can + * tell a silent model from a running tool. Served as `toolCallSpans` on `/v1/capabilities`; + * agent-runtime runs `modelSilenceMs` only on a route that declares it. + */ + readonly toolCallSpans?: boolean /** Does this backend want to handle requests for `model`? */ matches(model: string): boolean diff --git a/src/sessions/retained.ts b/src/sessions/retained.ts index 8244df8..b654c38 100644 --- a/src/sessions/retained.ts +++ b/src/sessions/retained.ts @@ -146,14 +146,17 @@ export class RetainedSessionService { return parseNativeContinuation(value) } - async capabilities(model: string, signal?: AbortSignal): Promise { - const { capabilities } = await readyBackendCapabilities({ + async capabilities( + model: string, + signal?: AbortSignal, + ): Promise { + const { backend, capabilities } = await readyBackendCapabilities({ registry: this.registry, model, healthProbeTimeoutMs: this.healthProbeTimeoutMs, signal, }) - return capabilities + return backend.toolCallSpans ? { ...capabilities, toolCallSpans: true } : capabilities } async create(input: RetainedCreateInput, signal?: AbortSignal): Promise { diff --git a/tests/retained-sessions.test.ts b/tests/retained-sessions.test.ts index 03ed6db..37eb87d 100644 --- a/tests/retained-sessions.test.ts +++ b/tests/retained-sessions.test.ts @@ -1077,6 +1077,14 @@ describe('retained Agent Interface sessions', () => { }) expect(capabilities).not.toHaveProperty('nativeContinuation') expect(capabilities).not.toHaveProperty('interactions') + expect(capabilities).not.toHaveProperty('toolCallSpans') + }) + + it('declares toolCallSpans only for a backend whose stream reports a call at its start and end', async () => { + fixture = setup(Object.assign(new OneShotBackend('opencode'), { toolCallSpans: true })) + const response = await fixture.app.request('/v1/capabilities?model=opencode%2Ftest') + expect(response.status).toBe(200) + expect(await json(response)).toMatchObject({ toolCallSpans: true, streaming: { live: true } }) }) it('refuses retained capability discovery and creation until native health is ready', async () => {