diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 2580eff3..fadc1ad5 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -11716,6 +11716,12 @@ Stable, caller-owned cli-bridge session id for harness-side resume. Defaults Per-resume-turn inference cap before the worker settles on its last output. Mirrors `routerToolsInlineExecutor.maxTurns`; default 200 (runaway backstop). +##### activityWindow? + +> `optional` **activityWindow?**: `number` + +Newest-last activity window `progress()` reports. Default 12 (matches `PiSeam`). + *** ### ProviderSeam @@ -13896,6 +13902,16 @@ False when the call was observed but its original arguments were unavailable. > `readonly` `optional` **status?**: `"error"` \| `"ok"` +##### statusCaptured? + +> `readonly` `optional` **statusCaptured?**: `boolean` + +False when the source observed the call being MADE but never observed it finishing — so no +outcome is knowable, not even by default. Some wires (cli-bridge's OpenAI-shaped `tool_calls` +deltas) report the model's DECISION to call a tool and never report the call's result at all. +Without this marker such a call would project as `status: 'ok'` and be counted as a success in +every downstream error-rate read. Set it and the span carries NO status, which is the truth. + ##### result? > `readonly` `optional` **result?**: `unknown` diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index f4a1fee0..072024ad 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -1145,6 +1145,21 @@ function killWithGrace( */ /** Resolve the bridge wire model for this spawn. Per-create matrix settings win, then the * canonical profile's harness/model preferences, then the bridge's configured fallback. */ +/** + * A profile's selected model with its provider attached, the way a harness addresses one. + * + * Returns undefined when the profile selects no model, and the id unchanged when it is already + * qualified or when the profile names no provider — there, the harness's own provider resolution + * IS the caller's declared intent rather than a gap to fill. + */ +function qualifyProviderModel(model: AgentProfile['model']): string | undefined { + const id = model?.default + if (!id) return undefined + const provider = model?.provider + if (!provider || id.includes('/')) return id + return `${provider}/${id}` +} + function bridgeCellModel( seamModel: string | undefined, ctx: ExecutorContext, @@ -1156,7 +1171,16 @@ function bridgeCellModel( const backend = create?.backend const profileHarness = profile.harness === 'cli-base' ? undefined : profile.harness const harness = backend?.type ?? profileHarness - const model = backend?.model?.model ?? profile.model?.default + // The PROVIDER rides with the model. A harness addresses a model as `provider/model`, so a wire + // id built from `model.default` alone loses it: `{provider:'tangle-router', default:'glm-5.2'}` + // becomes `pi/glm-5.2`, which routes to the right BACKEND and then hands pi a bare id it cannot + // place. pi falls back to its own default provider and dies with "No API key found for + // " — a credential error naming a provider the caller never chose. Measured live; + // the same request with `pi/tangle-router/glm-5.2` returns 200. + // + // A per-cell `backend.model.model` override is left exactly as supplied: it is a caller-authored + // wire id, not a profile hint, and qualifying it would rewrite what the caller asked for. + const model = backend?.model?.model ?? qualifyProviderModel(profile.model) if (!harness && !model) return seamModel if (!harness) return model if (model) return model.startsWith(`${harness}/`) ? model : `${harness}/${model}` diff --git a/tests/runtime/bridge-executor.test.ts b/tests/runtime/bridge-executor.test.ts index e7ff11d9..49714c41 100644 --- a/tests/runtime/bridge-executor.test.ts +++ b/tests/runtime/bridge-executor.test.ts @@ -709,3 +709,60 @@ function isUsageStream(value: unknown): value is AsyncIterable { typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function' ) } + +describe('profile-selected model keeps its provider', () => { + // A harness addresses a model as `provider/model`. Building the wire id from `model.default` + // alone dropped the provider: `{provider:'tangle-router', default:'glm-5.2'}` became + // `pi/glm-5.2`, which routes to the right BACKEND and then hands pi a bare id it cannot place. + // pi fell back to its own default provider and died with "No API key found for opencode" — a + // credential error naming a provider nobody chose. Measured live against a real cli-bridge: + // `pi/tangle-router/glm-5.2` returns 200, `pi/glm-5.2` does not. + async function wireModelFor(profile: Record): Promise { + const seen: Array> = [] + bridgeHttpHandler = (payload) => { + seen.push(payload) + return sse('ok', 1, 2) + } + const executor = createExecutor({ + backend: 'bridge', + bridgeUrl: 'http://bridge.test', + bridgeBearer: 'secret', + model: 'pi/seam-default', + })({ profile, harness: null } as unknown as AgentSpec, { + signal: new AbortController().signal, + seams: {}, + }) + const run = executor.execute('go', new AbortController().signal) + if (!isUsageStream(run)) throw new Error('bridge worker must stream usage') + for await (const _event of run) { + // drain + } + return seen[0]?.model + } + + it('composes backend/provider/model when the profile names a provider', async () => { + expect( + await wireModelFor({ + name: 'w', + harness: 'pi', + model: { provider: 'tangle-router', default: 'glm-5.2' }, + }), + ).toBe('pi/tangle-router/glm-5.2') + }) + + it('leaves a model with no declared provider to the harness own resolution', async () => { + expect(await wireModelFor({ name: 'w', harness: 'pi', model: { default: 'glm-5.2' } })).toBe( + 'pi/glm-5.2', + ) + }) + + it('does not double-qualify a model that already carries its provider', async () => { + expect( + await wireModelFor({ + name: 'w', + harness: 'pi', + model: { provider: 'tangle-router', default: 'tangle-router/glm-5.2' }, + }), + ).toBe('pi/tangle-router/glm-5.2') + }) +})