Skip to content

Commit 45e5802

Browse files
committed
fix(login,workflow): keep the output schema on retry and persist the picked effort
Two defects a review swarm surfaced, both verified against source and both reproduced by a failing test before the fix. A workflow subagent lost its output schema whenever a provider rate limit forced its turn to be retried: the retry path never forwarded the schema, so the model was not offered the StructuredOutput tool, answered in prose, and the batch recorded it as completed rather than as a schema failure. The structured-output contract was void on exactly the path most likely to be taken under load, with no trace. Existing coverage only exercised the spawn path, which is why this stayed green. Login stored only an on/off thinking flag, so picking low, medium, or xhigh reopened the session at high, and an OpenAI Codex login reopened at the model's maximum effort no matter what was chosen. The level now travels with the boolean, and a level the model does not declare is still ignored rather than persisted. The TUI's own model picker also built its aliases by hand and dropped the declared efforts, so it now shares the one converter. Also adds the changesets this branch was missing: the size guideline and kill switch, the fan-out caps, run correlation, and the ACP method rename.
1 parent f843913 commit 45e5802

15 files changed

Lines changed: 167 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Rename the ACP authentication method to reflect that login is multi-provider: it now reads "Log in with a provider" and explains that the provider is chosen in a terminal. Clients matching the previous wording will need updating.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing low, medium, or xhigh reopened the session at high, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Keep a Dynamic Workflow subagent's output schema when a provider rate limit forces its turn to be retried. The retried turn lost the schema, so the subagent answered in prose and the workflow reported it as completed rather than as a schema failure.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Give every Dynamic Workflow run an id and stamp it on the subagent events it produces, so a client can tell which run a given subagent belongs to when several are in flight.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Add two ways to rein in Dynamic Workflow fan-out: `disableWorkflows` turns the tool off entirely, and `workflowSizeGuideline` sets an advisory ceiling that is mentioned to the model and warned about, on every surface, when a run exceeds it. Both are settable in config or by environment variable.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
Bound subagent fan-out with hard caps: 128 subagents per call, 200 per session, and a nesting depth of 3. Nesting was previously unbounded, so a workflow that spawned workflows could grow without limit; past depth 3 the call now fails instead.

apps/pythinker-code/src/tui/commands/prompts.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import {
44
DEFAULT_CATALOG_URL,
55
fetchCatalog,
66
loadBuiltInCatalog,
7+
managedModelToAlias,
78
type Catalog,
89
type CatalogModel,
910
type ModelAlias,
1011
type PlatformSelection,
1112
} from '@pythoughts/pythinker-code-sdk';
12-
import { capabilitiesForModel } from '@pythoughts/pythinker-code-oauth';
1313
import type {
1414
ManagedKimiCodeModelInfo,
1515
OpenPlatformDefinition,
@@ -160,13 +160,7 @@ export async function promptModelSelectionForOpenPlatform(
160160
): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined> {
161161
const modelDict: Record<string, ModelAlias> = {};
162162
for (const m of models) {
163-
modelDict[`${platform.id}/${m.id}`] = {
164-
provider: platform.id,
165-
model: m.id,
166-
maxContextSize: m.contextLength,
167-
capabilities: capabilitiesForModel(m),
168-
displayName: m.displayName,
169-
};
163+
modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m);
170164
}
171165
const selection = await runModelSelector(host, modelDict);
172166
if (selection === undefined) return undefined;

packages/agent-core/src/agent/turn/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,13 @@ export class TurnFlow {
191191
return this.launch(input, origin);
192192
}
193193

194-
retry(trigger?: string): number | null {
195-
return this.prompt([], { kind: 'retry', trigger });
194+
/**
195+
* Re-runs the turn. The schema must be passed again: a retried turn builds a
196+
* fresh StructuredOutputState, and without one the model is never offered the
197+
* StructuredOutput tool, so a schema'd subagent would quietly answer in prose.
198+
*/
199+
retry(trigger?: string, outputSchema?: Record<string, unknown>): number | null {
200+
return this.prompt([], { kind: 'retry', trigger }, outputSchema);
196201
}
197202

198203
private launch(

packages/agent-core/src/session/subagent-host.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,10 @@ export class SessionSubagentHost {
312312
this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions),
313313
);
314314
this.emitSubagentStarted(parent, agentId, runOptions);
315-
const turnId = child.turn.retry('agent-host');
315+
// The schema has to ride along: waitForChildCompletion still branches on
316+
// runOptions.outputSchema, so a retry that dropped it would skip the
317+
// continuation AND find no structured output, silently yielding prose.
318+
const turnId = child.turn.retry('agent-host', runOptions.outputSchema);
316319
if (turnId === null) {
317320
throw new Error(`Agent instance "${agentId}" could not start a retry turn`);
318321
}

packages/agent-core/test/session/subagent-host.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,6 +1646,65 @@ describe('SessionSubagentHost', () => {
16461646
expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']);
16471647
});
16481648

1649+
it('keeps the output schema when a rate-limited subagent is retried', async () => {
1650+
const parent = testAgent();
1651+
parent.configure();
1652+
parent.newEvents();
1653+
1654+
const outputSchema = {
1655+
type: 'object',
1656+
properties: { answer: { type: 'string' } },
1657+
required: ['answer'],
1658+
};
1659+
const toolNamesPerCall: string[][] = [];
1660+
let generateCalls = 0;
1661+
const generate: GenerateFn = async (
1662+
_provider,
1663+
_systemPrompt,
1664+
tools,
1665+
_history,
1666+
callbacks,
1667+
) => {
1668+
toolNamesPerCall.push(tools.map((tool) => tool.name));
1669+
generateCalls += 1;
1670+
if (generateCalls === 1) {
1671+
throw new APIStatusError(429, 'Rate limited', 'req-429');
1672+
}
1673+
// Answers in prose instead of calling StructuredOutput.
1674+
await callbacks?.onMessagePart?.({ type: 'text', text: 'plain prose answer' });
1675+
return textResult('plain prose answer');
1676+
};
1677+
const child = testAgent({
1678+
generate,
1679+
initialConfig: { providers: {}, loopControl: { maxRetriesPerStep: 1 } },
1680+
});
1681+
child.configure();
1682+
1683+
const session = fakeSession(parent.agent, child.agent);
1684+
const host = new SessionSubagentHost(session, 'main');
1685+
const retrySpy = vi.spyOn(child.agent.turn, 'retry');
1686+
1687+
const options = {
1688+
profileName: 'coder',
1689+
outputSchema,
1690+
parentToolCallId: 'call_agent',
1691+
prompt: 'Return structured output',
1692+
description: 'Structured task',
1693+
runInBackground: false,
1694+
signal,
1695+
};
1696+
const handle = await host.spawn(options);
1697+
await expect(handle.completion).rejects.toThrow('Rate limited');
1698+
1699+
const retryHandle = await host.retry(handle.agentId, options);
1700+
1701+
// Dropping the schema here used to let the retried turn answer in prose and
1702+
// report completed, silently voiding the structured-output contract.
1703+
await expect(retryHandle.completion).rejects.toThrow('structured_output.max_retries');
1704+
expect(retrySpy.mock.calls[0]?.[1]).toBe(outputSchema);
1705+
expect(toolNamesPerCall.at(-1)).toContain('StructuredOutput');
1706+
});
1707+
16491708
it('realigns a resumed subagent to the parent agent current model', async () => {
16501709
const parent = testAgent();
16511710
parent.configure();

0 commit comments

Comments
 (0)