{lanes.map((lane) => (
diff --git a/apps/vscode/webview-ui/src/components/login-outcome.ts b/apps/vscode/webview-ui/src/components/login-outcome.ts
new file mode 100644
index 00000000..ae772a82
--- /dev/null
+++ b/apps/vscode/webview-ui/src/components/login-outcome.ts
@@ -0,0 +1,14 @@
+export type LoginState = "idle" | "pending" | "error";
+
+/**
+ * How a finished login maps onto the login screen. Login is multi-provider, so
+ * a falsy `success` is usually a cancelled picker rather than a failure: only a
+ * result carrying an error message may render the error screen.
+ */
+export function loginOutcomeState(result: { success: boolean; error?: string }): {
+ state: LoginState;
+ error: string | null;
+} {
+ if (result.error) return { state: "error", error: result.error };
+ return { state: "idle", error: null };
+}
diff --git a/apps/vscode/webview-ui/src/stores/chat.store.ts b/apps/vscode/webview-ui/src/stores/chat.store.ts
index 6825ae33..bdee7f2f 100644
--- a/apps/vscode/webview-ui/src/stores/chat.store.ts
+++ b/apps/vscode/webview-ui/src/stores/chat.store.ts
@@ -37,6 +37,12 @@ export interface UISubagentStatus {
resultSummary?: string;
}
+export interface UIWorkflowWarning {
+ message: string;
+ agentCount: number;
+ threshold: number;
+}
+
export interface InlineError {
code: string;
message: string;
@@ -55,6 +61,7 @@ export type UIStepItem =
result?: ToolResult["return_value"];
subagent_steps?: UIStep[];
subagent_status?: Record
;
+ workflow_warning?: UIWorkflowWarning;
};
export interface ChatMessage {
diff --git a/apps/vscode/webview-ui/src/stores/event-handlers.ts b/apps/vscode/webview-ui/src/stores/event-handlers.ts
index 2bd1ab0b..f6822e74 100644
--- a/apps/vscode/webview-ui/src/stores/event-handlers.ts
+++ b/apps/vscode/webview-ui/src/stores/event-handlers.ts
@@ -3,7 +3,7 @@ import { useApprovalStore } from "./approval.store";
import { useSettingsStore } from "./settings.store";
import { isPreflightError, isUserInterrupt } from "shared/errors";
import type { ChatMessage, UIStep, UIStepItem, UISubagentStatus, ChatState, TokenUsage } from "./chat.store";
-import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, SubagentStatusPayload, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk";
+import type { ContentPart, ToolCall, ToolResult, TurnBegin, SubagentEvent, SubagentStatusPayload, WorkflowWarningPayload, ApprovalRequestPayload, DiffBlock, RunResult, QuestionRequest } from "shared/legacy-sdk";
import type { UIStreamEvent, StreamError } from "shared/types";
type EventHandler = (draft: ChatState, payload: any) => void;
@@ -603,6 +603,24 @@ const eventHandlers: Record = {
applySubagentStatus(toolItem, payload);
},
+ WorkflowWarning: (draft, payload: WorkflowWarningPayload) => {
+ const last = getLastAssistant(draft);
+ if (!last?.steps) {
+ return;
+ }
+
+ const toolItem = findToolUseItem(last.steps, payload.parent_tool_call_id);
+ if (!toolItem) {
+ return;
+ }
+
+ toolItem.workflow_warning = {
+ message: payload.message,
+ agentCount: payload.agent_count,
+ threshold: payload.threshold,
+ };
+ },
+
ApprovalRequest: (_, payload: ApprovalRequestPayload) => {
useApprovalStore.getState().addRequest({
id: payload.id,
diff --git a/flake.nix b/flake.nix
index 27c405f1..674e910b 100644
--- a/flake.nix
+++ b/flake.nix
@@ -148,7 +148,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
- hash = "sha256-9DehARrDfyGqxEqDmfeYTw1tIFqNQVxpOpiaW6QY4Zw=";
+ hash = "sha256-GKfwTPY1YSCXyTICa/T2SrkqitM1EL/7l3Ry0VfNtY0=";
};
nativeBuildInputs = [
diff --git a/packages/acp-adapter/src/auth-methods.ts b/packages/acp-adapter/src/auth-methods.ts
index aaa3980d..ee23592e 100644
--- a/packages/acp-adapter/src/auth-methods.ts
+++ b/packages/acp-adapter/src/auth-methods.ts
@@ -36,8 +36,8 @@ export function buildTerminalAuthMethod(
const method: AuthMethod = {
id: 'login',
type: 'terminal',
- name: 'Login with Pythinker account',
- description: 'Open the device-code login flow in a terminal.',
+ name: 'Login with a provider',
+ description: 'Choose a provider and complete login in a terminal.',
// Appended to the agent's configured args by spec-compliant clients
// (e.g. `args:['acp']` + `args:['--login']` → `acp --login`). The
// `--login` flag on `pythinker acp` pivots into the login flow before
@@ -49,7 +49,7 @@ export function buildTerminalAuthMethod(
(method as AuthMethod & { _meta: { 'terminal-auth': unknown } })._meta = {
'terminal-auth': {
type: 'terminal',
- label: 'Login with Pythinker account',
+ label: 'Login with a provider',
// Legacy clients use this verbatim as the executable path, NOT
// combined with the agent server's configured command (per Zed's
// `meta_terminal_auth_task` in `agent_servers/src/acp.rs`).
diff --git a/packages/acp-adapter/test/server.test.ts b/packages/acp-adapter/test/server.test.ts
index 451e1cfd..be60b5fa 100644
--- a/packages/acp-adapter/test/server.test.ts
+++ b/packages/acp-adapter/test/server.test.ts
@@ -182,7 +182,7 @@ describe('AcpServer + AgentSideConnection', () => {
// Legacy _meta fallback uses absolute command + 'login' subcommand.
expect(method._meta?.['terminal-auth']).toEqual({
type: 'terminal',
- label: 'Login with Pythinker account',
+ label: 'Login with a provider',
command: '/abs/path/to/pythinker',
args: ['login'],
env: { PYTHINKER_CODE_HOME: '/tmp/pythinker-debug' },
diff --git a/packages/agent-core/src/agent/dynamic-workflow/index.ts b/packages/agent-core/src/agent/dynamic-workflow/index.ts
index c2593074..4d8e4127 100644
--- a/packages/agent-core/src/agent/dynamic-workflow/index.ts
+++ b/packages/agent-core/src/agent/dynamic-workflow/index.ts
@@ -2,6 +2,7 @@ import type { Agent } from '..';
import DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import DYNAMIC_WORKFLOW_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
+import { resolveWorkflowSizeGuideline, workflowSizeGuidelineNote } from './size-guideline';
/**
* manual = persistent toggle;
@@ -20,10 +21,18 @@ export class DynamicWorkflowMode {
this.agent.records.logRecord({ type: 'dynamic_workflow_mode.enter', trigger });
this.active = trigger;
if (trigger !== 'tool') {
- this.agent.context.appendSystemReminder(DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER, {
- kind: 'injection',
- variant: 'dynamic_workflow_mode',
- });
+ const sizeNote = workflowSizeGuidelineNote(
+ resolveWorkflowSizeGuideline(this.agent.pythinkerConfig),
+ );
+ this.agent.context.appendSystemReminder(
+ sizeNote === undefined
+ ? DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER
+ : `${DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER}\n\n${sizeNote}`,
+ {
+ kind: 'injection',
+ variant: 'dynamic_workflow_mode',
+ },
+ );
}
this.agent.emitStatusUpdated();
}
diff --git a/packages/agent-core/src/agent/dynamic-workflow/run-id.ts b/packages/agent-core/src/agent/dynamic-workflow/run-id.ts
new file mode 100644
index 00000000..50f4dbf4
--- /dev/null
+++ b/packages/agent-core/src/agent/dynamic-workflow/run-id.ts
@@ -0,0 +1,14 @@
+import { randomBytes } from 'node:crypto';
+
+/** Matches a generated run id. Also the guard for a run id that arrives as tool input. */
+const WORKFLOW_RUN_ID_PATTERN = /^wfr-[0-9a-z]{1,32}-[0-9a-z]{1,16}$/u;
+
+export function isWorkflowRunId(value: string): boolean {
+ return WORKFLOW_RUN_ID_PATTERN.test(value);
+}
+
+/** Timestamp plus crypto randomness, both lowercase — no character that means
+ * anything to a filesystem or a shell. */
+export function generateWorkflowRunId(): string {
+ return `wfr-${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
+}
diff --git a/packages/agent-core/src/agent/dynamic-workflow/size-guideline.ts b/packages/agent-core/src/agent/dynamic-workflow/size-guideline.ts
new file mode 100644
index 00000000..cdb30321
--- /dev/null
+++ b/packages/agent-core/src/agent/dynamic-workflow/size-guideline.ts
@@ -0,0 +1,55 @@
+import { resolveConfigValue, type PythinkerConfig, type WorkflowSizeGuideline } from '../../config';
+
+export const WORKFLOW_SIZE_GUIDELINE_ENV = 'PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE';
+
+export const DEFAULT_WORKFLOW_SIZE_GUIDELINE: WorkflowSizeGuideline = 'medium';
+
+/** Advisory subagent-count target per guideline. `unrestricted` has none. */
+const GUIDELINE_TARGETS: Record = {
+ small: 5,
+ medium: 15,
+ large: 40,
+ unrestricted: undefined,
+};
+
+// Derived from the targets rather than listed again: the record is
+// exhaustiveness-checked against WorkflowSizeGuideline, so a new guideline
+// cannot be accepted by the env parser without also getting a target.
+const WORKFLOW_SIZE_GUIDELINE_NAMES = new Set(Object.keys(GUIDELINE_TARGETS));
+
+/** The numeric subagent-count target for a guideline, or `undefined` for `unrestricted`. */
+export function workflowSizeGuidelineTarget(guideline: WorkflowSizeGuideline): number | undefined {
+ return GUIDELINE_TARGETS[guideline];
+}
+
+function parseWorkflowSizeGuidelineEnv(value: string | undefined): WorkflowSizeGuideline | undefined {
+ const normalized = value?.trim().toLowerCase();
+ if (normalized === undefined || !WORKFLOW_SIZE_GUIDELINE_NAMES.has(normalized)) return undefined;
+ return normalized as WorkflowSizeGuideline;
+}
+
+export function resolveWorkflowSizeGuideline(
+ config: Pick | undefined,
+ env: Readonly> = process.env,
+): WorkflowSizeGuideline {
+ return resolveConfigValue({
+ env,
+ envKey: WORKFLOW_SIZE_GUIDELINE_ENV,
+ configValue: config?.workflowSizeGuideline,
+ defaultValue: DEFAULT_WORKFLOW_SIZE_GUIDELINE,
+ parseEnv: parseWorkflowSizeGuidelineEnv,
+ });
+}
+
+/**
+ * Note appended to the model-facing Dynamic Workflow guidance, or `undefined` for
+ * `unrestricted` (which keeps the existing decompose-freely wording).
+ *
+ * The base guidance tells the model to decompose as finely as possible, so the note has to
+ * say out loud that it supersedes that — otherwise the model reads two conflicting targets.
+ */
+export function workflowSizeGuidelineNote(guideline: WorkflowSizeGuideline): string | undefined {
+ const target = GUIDELINE_TARGETS[guideline];
+ if (target === undefined) return undefined;
+ return `Workflow size guideline: this supersedes the guidance above about decomposing as finely as possible. Unless the user explicitly asks for more, aim for at most about ${String(target)} subagents in one workflow, preferring fewer, larger items over many tiny ones.`;
+}
diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts
index b8efbb8e..da144b43 100644
--- a/packages/agent-core/src/agent/tool/index.ts
+++ b/packages/agent-core/src/agent/tool/index.ts
@@ -12,6 +12,8 @@ import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming';
import type { MCPClient } from '../../mcp/types';
import { extendWorkspaceWithSkillRoots } from '../../skill';
import * as b from '../../tools/builtin';
+import { isDynamicWorkflowDisabled } from '../../tools/builtin/collaboration/dynamic-workflow';
+import { resolveWorkflowSizeGuideline } from '../dynamic-workflow/size-guideline';
import type { ToolStore, ToolStoreData, ToolStoreKey } from '../../tools/store';
import type {
BuiltinTool,
@@ -466,6 +468,8 @@ export class ToolManager {
this.agent.lsp !== undefined &&
this.agent.lsp.hasServers &&
this.agent.experimentalFlags.enabled('lsp');
+ const dynamicWorkflowEnabled = !isDynamicWorkflowDisabled(this.agent.pythinkerConfig);
+ const workflowSizeGuideline = resolveWorkflowSizeGuideline(this.agent.pythinkerConfig);
const builtinTools = new Map(
[
new b.ReadTool(kaos, workspace, this.fileReadState),
@@ -556,7 +560,13 @@ export class ToolManager {
},
),
this.agent.subagentHost &&
- new b.DynamicWorkflowTool(this.agent.subagentHost, this.agent.dynamicWorkflowMode),
+ dynamicWorkflowEnabled &&
+ new b.DynamicWorkflowTool(
+ this.agent.subagentHost,
+ this.agent.dynamicWorkflowMode,
+ workflowSizeGuideline,
+ (event) => this.agent.emitEvent(event),
+ ),
toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher),
toolServices?.urlFetcher && new b.FetchURLTool(toolServices.urlFetcher, kaos),
]
diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts
index b60a185b..47cda036 100644
--- a/packages/agent-core/src/agent/turn/index.ts
+++ b/packages/agent-core/src/agent/turn/index.ts
@@ -191,8 +191,13 @@ export class TurnFlow {
return this.launch(input, origin);
}
- retry(trigger?: string): number | null {
- return this.prompt([], { kind: 'retry', trigger });
+ /**
+ * Re-runs the turn. The schema must be passed again: a retried turn builds a
+ * fresh StructuredOutputState, and without one the model is never offered the
+ * StructuredOutput tool, so a schema'd subagent would quietly answer in prose.
+ */
+ retry(trigger?: string, outputSchema?: Record): number | null {
+ return this.prompt([], { kind: 'retry', trigger }, outputSchema);
}
private launch(
diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts
index a504c336..257812ce 100644
--- a/packages/agent-core/src/config/schema.ts
+++ b/packages/agent-core/src/config/schema.ts
@@ -74,6 +74,10 @@ export type ThinkingConfig = z.infer;
export const PermissionModeSchema = z.enum(['yolo', 'manual', 'auto']);
+export const WorkflowSizeGuidelineSchema = z.enum(['small', 'medium', 'large', 'unrestricted']);
+
+export type WorkflowSizeGuideline = z.infer;
+
export const PermissionRuleDecisionSchema = z.enum(['allow', 'deny', 'ask']);
export const PermissionRuleScopeSchema = z.enum([
'turn-override',
@@ -306,6 +310,8 @@ export const PythinkerConfigSchema = z.object({
background: BackgroundConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
telemetry: z.boolean().optional(),
+ disableWorkflows: z.boolean().optional(),
+ workflowSizeGuideline: WorkflowSizeGuidelineSchema.optional(),
raw: z.record(z.string(), z.unknown()).optional(),
});
@@ -349,6 +355,8 @@ export const PythinkerConfigPatchSchema = z
background: BackgroundConfigPatchSchema.optional(),
experimental: ExperimentalConfigPatchSchema.optional(),
telemetry: z.boolean().optional(),
+ disableWorkflows: z.boolean().optional(),
+ workflowSizeGuideline: WorkflowSizeGuidelineSchema.optional(),
})
.strict();
diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts
index 8270e4aa..2aaffe99 100644
--- a/packages/agent-core/src/config/toml.ts
+++ b/packages/agent-core/src/config/toml.ts
@@ -486,6 +486,8 @@ export function configToTomlData(config: PythinkerConfig): Record = {
readonly signal?: AbortSignal;
readonly modelAlias?: string;
readonly thinkingLevel?: string;
+ readonly workflowRunId?: string;
+ readonly workflowName?: string;
+ readonly outputSchema?: Record;
};
export type SpawnQueuedSubagentTask = BaseQueuedSubagentTask & {
@@ -70,7 +74,7 @@ export type QueuedSubagentTask =
export type SubagentResult = {
readonly task: QueuedSubagentTask;
readonly agentId?: string;
- readonly status: 'completed' | 'failed' | 'aborted';
+ readonly status: 'completed' | 'failed' | 'aborted' | 'schema_error';
readonly state?: 'started' | 'not_started';
readonly result?: string;
readonly usage?: TokenUsage;
@@ -96,6 +100,20 @@ type RateLimitedOutcome = {
readonly error: string;
};
+/**
+ * A child turn failed because it never produced valid structured output
+ * (ErrorCodes.STRUCTURED_OUTPUT_MAX_RETRIES). The batch reports that child as
+ * status 'schema_error' instead of 'failed': the child ran, but its
+ * deliverable was the structured output, so a schema miss is not a generic
+ * child failure and must not fail the rest of the batch.
+ */
+export class StructuredOutputMaxRetriesError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'StructuredOutputMaxRetriesError';
+ }
+}
+
type AttemptOutcome = SubagentResult | RateLimitedOutcome;
type TaskState = {
@@ -120,6 +138,7 @@ export class SubagentBatch {
private readonly states: Array>;
private readonly pending: Array>;
private readonly results: Array | undefined>;
+ private readonly concurrencyLimit: number;
private readonly active = new Set>();
private readonly controller = new AbortController();
private readonly batchSignal: AbortSignal | undefined;
@@ -143,7 +162,15 @@ export class SubagentBatch {
constructor(
private readonly launcher: SubagentBatchLauncher,
tasks: readonly QueuedSubagentTask[],
+ concurrencyLimit: number = MAX_CONCURRENT_WORKFLOW_SUBAGENTS,
) {
+ // Clamped rather than validated: a limit of 0 would stall the batch forever with no
+ // error to read, which is a worse failure than quietly running one at a time.
+ // NaN survives both Math.trunc and Math.max, and every `running < limit` test
+ // against it is false, so it stalls the batch the same way 0 would.
+ this.concurrencyLimit = Number.isFinite(concurrencyLimit)
+ ? Math.max(1, Math.trunc(concurrencyLimit))
+ : MAX_CONCURRENT_WORKFLOW_SUBAGENTS;
this.states = tasks.map((task, index) => ({
index,
task,
@@ -205,7 +232,8 @@ export class SubagentBatch {
while (
this.normalLaunchCount < INITIAL_LAUNCH_LIMIT &&
this.pending.length > 0 &&
- !this.rateLimitMode
+ !this.rateLimitMode &&
+ this.active.size < this.concurrencyLimit
) {
this.startAttempt(this.pending.shift()!);
this.normalLaunchCount += 1;
@@ -222,6 +250,12 @@ export class SubagentBatch {
this.normalLaunchTimer = setTimeout(() => {
this.normalLaunchTimer = undefined;
if (this.finished || this.rateLimitMode || this.pending.length === 0) return;
+ if (this.active.size >= this.concurrencyLimit) {
+ // The concurrency cap blocks the ramp; re-arm rather than dropping
+ // the wakeup so the batch retries once a slot frees.
+ this.schedule();
+ return;
+ }
this.startAttempt(this.pending.shift()!);
this.normalLaunchCount += 1;
this.schedule();
@@ -234,7 +268,7 @@ export class SubagentBatch {
const now = Date.now();
this.recoverRateLimitCapacity(now);
- if (this.active.size >= this.rateLimitCapacity) {
+ if (this.active.size >= this.effectiveRateLimitCapacity()) {
this.scheduleRateLimitWakeup(this.nextRateLimitCapacityRecoveryAt(), now);
return;
}
@@ -290,6 +324,9 @@ export class SubagentBatch {
runInBackground: task.runInBackground,
modelAlias: task.modelAlias,
thinkingLevel: task.thinkingLevel,
+ workflowRunId: task.workflowRunId,
+ workflowName: task.workflowName,
+ outputSchema: task.outputSchema,
signal: attempt.controller.signal,
onReady: () => {
this.markAttemptReady(attempt);
@@ -342,7 +379,7 @@ export class SubagentBatch {
const status =
attempt.controller.signal.aborted && isUserCancellation(attempt.controller.signal.reason)
? 'aborted'
- : 'failed';
+ : this.attemptFailureStatus(error);
return {
task: attempt.state.task,
agentId: attempt.state.agentId,
@@ -352,6 +389,10 @@ export class SubagentBatch {
};
}
+ private attemptFailureStatus(error: unknown): SubagentResult['status'] {
+ return error instanceof StructuredOutputMaxRetriesError ? 'schema_error' : 'failed';
+ }
+
private markAttemptReady(attempt: ActiveAttempt): void {
if (this.finished || attempt.ready || !this.active.has(attempt)) return;
@@ -394,7 +435,7 @@ export class SubagentBatch {
this.results[attempt.state.index] = {
task: attempt.state.task,
agentId: attempt.state.agentId,
- status: 'failed',
+ status: this.attemptFailureStatus(error),
error: error instanceof Error ? error.message : String(error),
};
this.schedule();
@@ -472,6 +513,14 @@ export class SubagentBatch {
this.lastCapacityShrinkAt = now;
}
+ /**
+ * Rate-limit capacity grows by 1 per quiet window, so on a long batch it can drift past
+ * the concurrency cap. The cap holds in both phases.
+ */
+ private effectiveRateLimitCapacity(): number {
+ return Math.min(this.rateLimitCapacity, this.concurrencyLimit);
+ }
+
private recoverRateLimitCapacity(now: number): void {
const nextRecoveryAt = this.nextRateLimitCapacityRecoveryAt();
if (nextRecoveryAt > now) return;
@@ -505,7 +554,7 @@ export class SubagentBatch {
if (this.pending.length === 0) return;
const nextWakeupAt =
- this.active.size >= this.rateLimitCapacity
+ this.active.size >= this.effectiveRateLimitCapacity()
? this.nextRateLimitCapacityRecoveryAt()
: Math.min(
Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt()),
diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts
index d9e1d554..1c092389 100644
--- a/packages/agent-core/src/session/subagent-host.ts
+++ b/packages/agent-core/src/session/subagent-host.ts
@@ -30,9 +30,10 @@ import {
type SettledSubagentWorktree,
type SubagentWorktree,
} from './subagent-worktree';
-import type { Session } from './index';
+import type { AgentMeta, Session } from './index';
import {
SubagentBatch,
+ StructuredOutputMaxRetriesError,
type SubagentResult,
type SubagentSuspendedEvent,
type QueuedSubagentTask,
@@ -41,6 +42,8 @@ import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw';
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes';
+export const MAX_SUBAGENTS_PER_SESSION = 200;
+export const MAX_SUBAGENT_SPAWN_DEPTH = 3;
export type {
SubagentResult as QueuedSubagentRunResult,
@@ -91,6 +94,9 @@ export interface RunSubagentOptions {
readonly suppressRateLimitFailureEvent?: boolean;
readonly modelAlias?: string;
readonly thinkingLevel?: string;
+ readonly workflowRunId?: string;
+ readonly workflowName?: string;
+ readonly outputSchema?: Record;
readonly allowedTools?: readonly string[];
readonly cwd?: string;
readonly forkContext?: boolean;
@@ -135,6 +141,7 @@ export class SessionSubagentHost {
if (options.forkContext === true && parent.type !== 'main') {
throw new Error('Fork is not available inside a forked worker');
}
+ this.enforceSpawnCaps();
const profile = options.forkContext === true
? undefined
: this.resolveProfile(parent, options.profileName);
@@ -232,6 +239,51 @@ export class SessionSubagentHost {
};
}
+ /**
+ * Runaway guards for new subagents. Both throw, which the batch scheduler turns into a
+ * single failed task rather than a failed batch, so the error text is written for the model
+ * that will read it. Only `spawn` is guarded — `resume` and `retry` reuse an agent that
+ * already exists and was already counted.
+ *
+ * The per-session count is read before `createAgent` awaits, so concurrent spawns can
+ * overshoot the cap by up to the batch concurrency limit. That is accepted: these are
+ * runaway guards, not quotas, and a reservation counter that has to be released on every
+ * failure path is a likelier source of leaks than the bounded overshoot it prevents.
+ */
+ private enforceSpawnCaps(): void {
+ const agents = this.session.metadata.agents;
+
+ let subagentCount = 0;
+ for (const meta of Object.values(agents)) {
+ if (meta.type === 'sub') subagentCount += 1;
+ }
+ if (subagentCount >= MAX_SUBAGENTS_PER_SESSION) {
+ throw new Error(
+ `This session is limited to ${String(MAX_SUBAGENTS_PER_SESSION)} subagents and can create no more; split the remaining work across fewer, larger subagents.`,
+ );
+ }
+
+ // The owner's depth is its number of ancestors, so the main agent is depth 0 and its
+ // child lands at depth 1. The walk is bounded by the agent count so malformed metadata
+ // with a parent cycle cannot hang the process.
+ let ownerDepth = 0;
+ let cursor: string | null = this.ownerAgentId;
+ const agentCount = Object.keys(agents).length;
+ for (let steps = 0; cursor !== null && steps <= agentCount; steps += 1) {
+ // Annotated because the Session -> Agent -> SessionSubagentHost module cycle makes tsc
+ // infer this read circularly (TS7022) when left implicit.
+ const meta: AgentMeta | undefined = agents[cursor];
+ if (meta === undefined) break;
+ cursor = meta.parentAgentId;
+ if (cursor !== null) ownerDepth += 1;
+ }
+ if (ownerDepth + 1 > MAX_SUBAGENT_SPAWN_DEPTH) {
+ throw new Error(
+ `Subagents may nest at most ${String(MAX_SUBAGENT_SPAWN_DEPTH)} levels deep (the main agent is depth 0), and this parent agent is already at depth ${String(ownerDepth)}; split the work across fewer, larger subagents instead of nesting deeper.`,
+ );
+ }
+ }
+
async resume(agentId: string, options: RunSubagentOptions): Promise {
options.signal.throwIfAborted();
const { parent, child, profileName } = await this.ensureIdleSubagent(agentId);
@@ -259,8 +311,11 @@ export class SessionSubagentHost {
child.config.update(
this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions),
);
- this.emitSubagentStarted(parent, agentId, runOptions.parentToolCallId);
- const turnId = child.turn.retry('agent-host');
+ this.emitSubagentStarted(parent, agentId, runOptions);
+ // The schema has to ride along: waitForChildCompletion still branches on
+ // runOptions.outputSchema, so a retry that dropped it would skip the
+ // continuation AND find no structured output, silently yielding prose.
+ const turnId = child.turn.retry('agent-host', runOptions.outputSchema);
if (turnId === null) {
throw new Error(`Agent instance "${agentId}" could not start a retry turn`);
}
@@ -460,8 +515,12 @@ export class SessionSubagentHost {
if (gitContext) childPrompt = `${gitContext}\n\n${childPrompt}`;
}
- this.emitSubagentStarted(parent, childId, options.parentToolCallId);
- const turnId = child.turn.prompt([{ type: 'text', text: childPrompt }], SUBAGENT_PROMPT_ORIGIN);
+ this.emitSubagentStarted(parent, childId, options);
+ const turnId = child.turn.prompt(
+ [{ type: 'text', text: childPrompt }],
+ SUBAGENT_PROMPT_ORIGIN,
+ options.outputSchema,
+ );
if (turnId === null) {
throw new Error(`Agent instance "${childId}" could not start a turn`);
}
@@ -476,26 +535,33 @@ export class SessionSubagentHost {
profileName: string,
options: RunSubagentOptions,
): Promise {
- await runChildTurnToCompletion(child, options.signal);
+ const turnResult = await runChildTurnToCompletion(child, options.signal);
// A subagent that returns an overly terse summary leaves the parent
// agent under-informed. Give it a bounded number of chances to expand
// the handoff; if it is still short after that, accept it as-is rather
- // than retrying indefinitely.
+ // than retrying indefinitely. When a schema is in effect the structured
+ // output — not the prose — is the deliverable, so the continuation never
+ // runs and the turn would cost an extra request without a schema.
let result = lastAssistantText(child);
- let remainingContinuations = SUMMARY_CONTINUATION_ATTEMPTS;
- while (remainingContinuations > 0 && result.length < SUMMARY_MIN_LENGTH) {
- remainingContinuations -= 1;
- options.signal.throwIfAborted();
- child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], SUBAGENT_PROMPT_ORIGIN);
- await runChildTurnToCompletion(child, options.signal);
- result = lastAssistantText(child);
+ if (options.outputSchema === undefined) {
+ let remainingContinuations = SUMMARY_CONTINUATION_ATTEMPTS;
+ while (remainingContinuations > 0 && result.length < SUMMARY_MIN_LENGTH) {
+ remainingContinuations -= 1;
+ options.signal.throwIfAborted();
+ child.turn.prompt([{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], SUBAGENT_PROMPT_ORIGIN);
+ await runChildTurnToCompletion(child, options.signal);
+ result = lastAssistantText(child);
+ }
+ } else if (turnResult.structuredOutput !== undefined) {
+ result = JSON.stringify(turnResult.structuredOutput);
}
const usage = child.usage.data().total;
parent.emitEvent({
type: 'subagent.completed',
subagentId: childId,
parentToolCallId: options.parentToolCallId,
+ workflowRunId: options.workflowRunId,
resultSummary: result,
usage,
contextTokens: child.context.tokenCount,
@@ -625,23 +691,27 @@ export class SessionSubagentHost {
parentAgentId: this.ownerAgentId,
description: options.description,
dynamicWorkflowIndex: options.dynamicWorkflowIndex,
+ workflowRunId: options.workflowRunId,
+ workflowName: options.workflowName,
runInBackground: options.runInBackground,
});
parent.telemetry.track('subagent_created', {
subagent_name: profileName,
run_in_background: options.runInBackground,
+ workflow_run_id: options.workflowRunId,
});
}
private emitSubagentStarted(
parent: Agent,
childId: string,
- parentToolCallId: string,
+ options: RunSubagentOptions,
): void {
parent.emitEvent({
type: 'subagent.started',
subagentId: childId,
- parentToolCallId,
+ parentToolCallId: options.parentToolCallId,
+ workflowRunId: options.workflowRunId,
});
}
@@ -656,18 +726,27 @@ export class SessionSubagentHost {
type: 'subagent.failed',
subagentId: childId,
parentToolCallId: options.parentToolCallId,
+ workflowRunId: options.workflowRunId,
error: error instanceof Error ? error.message : String(error),
});
}
}
-async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Promise {
+async function runChildTurnToCompletion(
+ child: Agent,
+ signal: AbortSignal,
+): Promise<{ readonly structuredOutput?: unknown }> {
const completion = await child.turn.waitForCurrentTurn(signal);
const turnEnded = completion.event;
if (turnEnded.reason !== 'completed') {
if (turnEnded.error?.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
throw providerRateLimitErrorFromPayload(turnEnded.error);
}
+ if (turnEnded.error?.code === ErrorCodes.STRUCTURED_OUTPUT_MAX_RETRIES) {
+ throw new StructuredOutputMaxRetriesError(
+ `[${turnEnded.error.code}] ${turnEnded.error.message}`,
+ );
+ }
throw new Error(
turnEnded.error === undefined
? `Subagent turn ${turnEnded.reason}`
@@ -677,6 +756,7 @@ async function runChildTurnToCompletion(child: Agent, signal: AbortSignal): Prom
if (completion.stopReason === 'max_tokens') {
throw new Error(`${SUBAGENT_MAX_TOKENS_ERROR}.`);
}
+ return { structuredOutput: turnEnded.structuredOutput };
}
function providerRateLimitErrorFromPayload(error: PythinkerErrorPayload): APIProviderRateLimitError {
diff --git a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.md b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.md
index 35b81104..0b7b9120 100644
--- a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.md
+++ b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.md
@@ -6,6 +6,8 @@ Use `resume_agent_ids` to continue subagents that already exist from earlier wor
Use `model` and `effort` to run this workflow's subagents on a different model than the one orchestrating them, such as a cheaper or faster model for mechanical work while the orchestration stays on the current model. Both apply to every subagent in the call. Omitting either falls back to the subagent type's own setting, and then to your current setting. A `model` that is not a configured alias also falls back to your current model rather than failing the workflow.
+The result carries a `run_id` attribute on its root element identifying the whole workflow run. Cite that id when referring to this run in later calls.
+
Use enough subagents to keep the work focused and parallel. DynamicWorkflow supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation.
If `DynamicWorkflow` is called, that call must be the only tool call in the response.
diff --git a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts
index bcf2b325..190ec7a5 100644
--- a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts
+++ b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts
@@ -1,5 +1,7 @@
import { z } from 'zod';
+import type { WorkflowWarningEvent } from '@pythoughts/protocol';
+
import type { DynamicWorkflowMode } from '../../../agent/dynamic-workflow';
import type { BuiltinTool } from '../../../agent/tool';
import type {
@@ -8,12 +10,43 @@ import type {
} from '../../../session/subagent-host';
import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../../loop/types';
+import { parseBooleanEnv, resolveConfigValue, type PythinkerConfig, type WorkflowSizeGuideline } from '../../../config';
+import {
+ DEFAULT_WORKFLOW_SIZE_GUIDELINE,
+ workflowSizeGuidelineNote,
+ workflowSizeGuidelineTarget,
+} from '../../../agent/dynamic-workflow/size-guideline';
+import { generateWorkflowRunId } from '../../../agent/dynamic-workflow/run-id';
import { toInputJsonSchema } from '../../support/input-schema';
import DYNAMIC_WORKFLOW_DESCRIPTION from './dynamic-workflow.md?raw';
const DEFAULT_SUBAGENT_TYPE = 'coder';
const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
const MAX_DYNAMIC_WORKFLOW_SUBAGENTS = 128;
+/**
+ * Warning threshold when the operator chose `unrestricted`, so even an
+ * unrestricted operator still hears about a very large fan-out.
+ */
+const UNRESTRICTED_WARNING_THRESHOLD = 25;
+
+export const DISABLE_WORKFLOWS_ENV = 'PYTHINKER_CODE_DISABLE_WORKFLOWS';
+
+/**
+ * Dynamic Workflow is off when the env var says so, else when the config key says so.
+ * Env wins so an operator can force it off without editing config.
+ */
+export function isDynamicWorkflowDisabled(
+ config: Pick | undefined,
+ env: Readonly> = process.env,
+): boolean {
+ return resolveConfigValue({
+ env,
+ envKey: DISABLE_WORKFLOWS_ENV,
+ configValue: config?.disableWorkflows,
+ defaultValue: false,
+ parseEnv: parseBooleanEnv,
+ });
+}
export const DynamicWorkflowToolInputSchema = z
.object({
@@ -39,8 +72,12 @@ export const DynamicWorkflowToolInputSchema = z
`Optional prompt template for each subagent. The ${PROMPT_TEMPLATE_PLACEHOLDER} placeholder is replaced with each item value. When omitted, each item is used as a complete prompt.`,
),
items: z
- .array(z.string().trim().min(1))
- .max(MAX_DYNAMIC_WORKFLOW_SUBAGENTS)
+ // Deliberately unconstrained per item and unbounded in length: argument
+ // validation rejects the WHOLE call before the tool runs, so a trailing
+ // empty string — or one blank entry pushing a full list one over the cap
+ // — would cost a re-send of every prompt. The tool drops blanks, reports
+ // how many, and enforces the real cap against the surviving count.
+ .array(z.string())
.optional()
.describe(
`Each item launches one new subagent. Items fill ${PROMPT_TEMPLATE_PLACEHOLDER} when prompt_template is provided; otherwise they are complete prompts.`,
@@ -67,6 +104,12 @@ export const DynamicWorkflowToolInputSchema = z
.describe(
'Reasoning effort for every subagent in this workflow. Defaults to the subagent type profile effort, then this agent effort.',
),
+ output_schema: z
+ .record(z.string(), z.unknown())
+ .optional()
+ .describe(
+ 'JSON Schema every subagent in this workflow must satisfy. Each subagent returns its result by calling the StructuredOutput tool instead of writing a prose summary. A subagent that cannot produce conforming output is reported with outcome="schema_error" and does not fail the batch.',
+ ),
})
.strict();
@@ -92,7 +135,7 @@ type DynamicWorkflowSpec = DynamicWorkflowSpawnSpec | DynamicWorkflowResumeSpec;
interface DynamicWorkflowRunResult {
readonly spec: DynamicWorkflowSpec;
readonly agentId?: string;
- readonly status: 'completed' | 'failed' | 'aborted';
+ readonly status: 'completed' | 'failed' | 'aborted' | 'schema_error';
readonly state?: 'started' | 'not_started';
readonly result?: string;
readonly error?: string;
@@ -100,16 +143,28 @@ interface DynamicWorkflowRunResult {
export class DynamicWorkflowTool implements BuiltinTool {
readonly name = 'DynamicWorkflow' as const;
- readonly description = DYNAMIC_WORKFLOW_DESCRIPTION;
+ readonly description: string;
readonly parameters: Record = toInputJsonSchema(DynamicWorkflowToolInputSchema);
constructor(
private readonly subagentHost: SessionSubagentHost,
private readonly dynamicWorkflowMode: DynamicWorkflowMode,
- ) {}
+ private readonly sizeGuideline: WorkflowSizeGuideline = DEFAULT_WORKFLOW_SIZE_GUIDELINE,
+ private readonly emitEvent?: (event: WorkflowWarningEvent) => void,
+ ) {
+ const sizeNote = workflowSizeGuidelineNote(this.sizeGuideline);
+ this.description =
+ sizeNote === undefined
+ ? DYNAMIC_WORKFLOW_DESCRIPTION
+ : `${DYNAMIC_WORKFLOW_DESCRIPTION}\n\n${sizeNote}`;
+ }
resolveExecution(args: DynamicWorkflowToolInput): ToolExecution {
- const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length;
+ // Count the items that will actually launch, so the panel never advertises
+ // a subagent that was never going to run.
+ const agentCount =
+ normalizeWorkflowItems(args.items).items.length +
+ Object.keys(args.resume_agent_ids ?? {}).length;
return {
accesses: ToolAccesses.all(),
description: `Launching Dynamic Workflow: ${args.description}`,
@@ -148,6 +203,19 @@ export class DynamicWorkflowTool implements BuiltinTool {
const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE;
const specs = createDynamicWorkflowSpecs(args, (agentId) => this.subagentHost.getDynamicWorkflowItem(agentId));
+ const runId = generateWorkflowRunId();
+ const threshold =
+ workflowSizeGuidelineTarget(this.sizeGuideline) ?? UNRESTRICTED_WARNING_THRESHOLD;
+ if (specs.length > threshold) {
+ this.emitEvent?.({
+ type: 'workflow.warning',
+ workflowRunId: runId,
+ parentToolCallId: toolCallId,
+ agentCount: specs.length,
+ threshold,
+ message: `This Dynamic Workflow will launch ${String(specs.length)} subagents, above the advisory ceiling of ${String(threshold)}; the run is proceeding anyway.`,
+ });
+ }
// Workflow tasks intentionally carry no timeout: they run until they
// complete, fail, or the user cancels (see dynamic-workflow.md).
const tasks = specs.map((spec): QueuedSubagentTask => {
@@ -166,6 +234,13 @@ export class DynamicWorkflowTool implements BuiltinTool ({ spec: task.data, ...result })));
+ const rendered = renderDynamicWorkflowResults(
+ results.map(({ task, ...result }) => ({ spec: task.data, ...result })),
+ runId,
+ );
+ const { dropped } = normalizeWorkflowItems(args.items);
+ if (dropped === 0) return rendered;
+ // After the envelope, never before it: consumers match the result document
+ // anchored at the start of the output, so a prefix makes a successful run
+ // parse as an unsupported result.
+ return `${rendered}\n${droppedItemsNote(dropped)}`;
}
}
@@ -193,12 +277,17 @@ function createDynamicWorkflowSpecs(
agentId: agentId.trim(),
prompt: prompt.trim(),
}));
- const items = (args.items ?? []).map((item) => item.trim());
+ const { items, dropped } = normalizeWorkflowItems(args.items);
const itemCount = items.length;
const resumeCount = resumeEntries.length;
const totalCount = resumeCount + itemCount;
if (!hasMinimumDynamicWorkflowInputs(itemCount, resumeCount)) {
- throw new Error('DynamicWorkflow requires at least 2 items unless resume_agent_ids is provided.');
+ // Name the dropped items here: without it the caller reads "requires at
+ // least 2 items" while looking at a list that had enough entries.
+ const droppedNote = dropped === 0 ? '' : ` ${droppedItemsNote(dropped)}`;
+ throw new Error(
+ `DynamicWorkflow requires at least 2 items unless resume_agent_ids is provided.${droppedNote}`,
+ );
}
if (totalCount > MAX_DYNAMIC_WORKFLOW_SUBAGENTS) {
throw new Error(`DynamicWorkflow supports at most ${String(MAX_DYNAMIC_WORKFLOW_SUBAGENTS)} subagents.`);
@@ -248,6 +337,28 @@ function hasMinimumDynamicWorkflowInputs(itemCount: number, resumeCount: number)
return resumeCount > 0 || itemCount >= 2;
}
+/**
+ * Trims items and drops the blank ones, reporting how many went.
+ *
+ * Models routinely emit a trailing empty string when building the list. Per
+ * item that is not worth failing the call over — but it is worth saying out
+ * loud, because a silently shorter workflow looks identical to one the model
+ * sized correctly.
+ */
+function normalizeWorkflowItems(raw: readonly string[] | undefined): {
+ items: string[];
+ dropped: number;
+} {
+ const trimmed = (raw ?? []).map((item) => item.trim());
+ const items = trimmed.filter((item) => item.length > 0);
+ return { items, dropped: trimmed.length - items.length };
+}
+
+function droppedItemsNote(dropped: number): string {
+ const plural = dropped === 1 ? 'item was' : 'items were';
+ return `Note: ${String(dropped)} empty ${plural} ignored; the workflow ran without them.`;
+}
+
function childDescription(workflowDescription: string, index: number, profileName: string): string {
return `${workflowDescription} #${String(index)} (${profileName})`;
}
@@ -255,16 +366,20 @@ function childDescription(workflowDescription: string, index: number, profileNam
// Render results as an XML block that the consumer parses, so every
// interpolated value (agent ids, item names, result/error bodies — all user
// data) must be escaped; an unescaped `<` or `>` would break the structure.
-function renderDynamicWorkflowResults(results: readonly DynamicWorkflowRunResult[]): string {
+function renderDynamicWorkflowResults(
+ results: readonly DynamicWorkflowRunResult[],
+ runId: string,
+): string {
const completed = results.filter((result) => result.status === 'completed').length;
const failed = results.filter((result) => result.status === 'failed').length;
const aborted = results.filter((result) => result.status === 'aborted').length;
+ const schemaError = results.filter((result) => result.status === 'schema_error').length;
const shouldRenderResumeHint = results.some(
(result) => result.status !== 'completed' && result.agentId !== undefined,
);
const lines = [
- '',
- `${renderDynamicWorkflowSummary(completed, failed, aborted)}`,
+ ``,
+ `${renderDynamicWorkflowSummary(completed, failed, aborted, schemaError)}`,
];
if (shouldRenderResumeHint) {
@@ -294,11 +409,19 @@ function normalizeOptionalString(value: string | undefined): string | undefined
return trimmed.length > 0 ? trimmed : undefined;
}
-function renderDynamicWorkflowSummary(completed: number, failed: number, aborted = 0): string {
+function renderDynamicWorkflowSummary(
+ completed: number,
+ failed: number,
+ aborted = 0,
+ schemaError = 0,
+): string {
const parts: string[] = [];
if (completed > 0) parts.push(`completed: ${String(completed)}`);
if (failed > 0) parts.push(`failed: ${String(failed)}`);
if (aborted > 0) parts.push(`aborted: ${String(aborted)}`);
+ // A schema_error child DID run, so it is neither completed nor aborted;
+ // it is counted separately and rendered with outcome="schema_error".
+ if (schemaError > 0) parts.push(`schema_error: ${String(schemaError)}`);
return parts.join(', ');
}
diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts
index 373431bb..b4f8514a 100644
--- a/packages/agent-core/test/agent/tool.test.ts
+++ b/packages/agent-core/test/agent/tool.test.ts
@@ -405,6 +405,34 @@ describe('Agent tools', () => {
expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(false);
});
+ it('skips DynamicWorkflow registration when disableWorkflows is set', () => {
+ const subagentHost = { getProfiles: () => ({}) } as unknown as SessionSubagentHost;
+
+ const ctx = testAgent({
+ subagentHost,
+ experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS),
+ initialConfig: { providers: {}, disableWorkflows: true },
+ });
+ ctx.configure({ tools: ['DynamicWorkflow'] });
+
+ expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'DynamicWorkflow')).toBe(false);
+ });
+
+ it('registers DynamicWorkflow with the configured size guideline in its description', () => {
+ const subagentHost = { getProfiles: () => ({}) } as unknown as SessionSubagentHost;
+
+ const ctx = testAgent({
+ subagentHost,
+ experimentalFlags: new FlagResolver({}, FLAG_DEFINITIONS),
+ initialConfig: { providers: {}, workflowSizeGuideline: 'small' },
+ });
+ ctx.configure({ tools: ['DynamicWorkflow'] });
+
+ const tool = ctx.agent.tools.loopTools.find((tool) => tool.name === 'DynamicWorkflow');
+ expect(tool).toBeDefined();
+ expect(tool!.description).toContain('about 5 subagents');
+ });
+
it('rejects a user tool whose name collides with a builtin before recording it', () => {
const ctx = testAgent();
ctx.configure();
diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts
index f5a98508..4a0a06e9 100644
--- a/packages/agent-core/test/agent/turn.test.ts
+++ b/packages/agent-core/test/agent/turn.test.ts
@@ -413,6 +413,32 @@ describe('Agent turn flow', () => {
await ctx.expectResumeMatches();
});
+ it('carries the workflow size guideline into the dynamic workflow mode reminder', async () => {
+ const restricted = testAgent({ initialConfig: { providers: {}, workflowSizeGuideline: 'small' } });
+ restricted.configure();
+ await restricted.rpc.enterDynamicWorkflow({ trigger: 'manual' });
+
+ const restrictedReminder = restricted.agent.context.history.at(-1);
+ expect(restrictedReminder?.origin).toEqual({
+ kind: 'injection',
+ variant: 'dynamic_workflow_mode',
+ });
+ expect(JSON.stringify(restrictedReminder)).toContain('about 5 subagents');
+
+ const unrestricted = testAgent({
+ initialConfig: { providers: {}, workflowSizeGuideline: 'unrestricted' },
+ });
+ unrestricted.configure();
+ await unrestricted.rpc.enterDynamicWorkflow({ trigger: 'manual' });
+
+ const unrestrictedReminder = unrestricted.agent.context.history.at(-1);
+ expect(unrestrictedReminder?.origin).toEqual({
+ kind: 'injection',
+ variant: 'dynamic_workflow_mode',
+ });
+ expect(JSON.stringify(unrestrictedReminder)).not.toContain('Workflow size guideline:');
+ });
+
it('exits task dynamic workflow mode after a turn completes normally', async () => {
const ctx = testAgent();
ctx.configure();
diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts
index b452bad4..e88e4fe4 100644
--- a/packages/agent-core/test/config/configs.test.ts
+++ b/packages/agent-core/test/config/configs.test.ts
@@ -304,6 +304,38 @@ micro_compaction = false
expect(parseConfigString(text, configPath).experimental).toEqual(config.experimental);
});
+ it('round-trips disableWorkflows as disable_workflows', async () => {
+ const dir = makeTempDir();
+ const configPath = join(dir, 'disable-workflows.toml');
+
+ expect(parseConfigString('disable_workflows = true\n', configPath).disableWorkflows).toBe(true);
+
+ // Written from an in-memory config with no `raw`, so the key only reaches the
+ // file through the scalar-field writer rather than being copied from `raw`.
+ await writeConfigFile(configPath, { providers: {}, disableWorkflows: true });
+ const text = await readFile(configPath, 'utf-8');
+
+ expect(text).toContain('disable_workflows = true');
+ expect(parseConfigString(text, configPath).disableWorkflows).toBe(true);
+ });
+
+ it('round-trips workflowSizeGuideline as workflow_size_guideline', async () => {
+ const dir = makeTempDir();
+ const configPath = join(dir, 'workflow-size-guideline.toml');
+
+ expect(
+ parseConfigString('workflow_size_guideline = "small"\n', configPath).workflowSizeGuideline,
+ ).toBe('small');
+
+ // Written from an in-memory config with no `raw`, so the key only reaches the
+ // file through the scalar-field writer rather than being copied from `raw`.
+ await writeConfigFile(configPath, { providers: {}, workflowSizeGuideline: 'small' });
+ const text = await readFile(configPath, 'utf-8');
+
+ expect(text).toContain('workflow_size_guideline = "small"');
+ expect(parseConfigString(text, configPath).workflowSizeGuideline).toBe('small');
+ });
+
it('accepts obsolete experimental feature keys as inert config', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'obsolete-experimental.toml');
diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts
index 772fd93c..3d047e0a 100644
--- a/packages/agent-core/test/harness/skill-session.test.ts
+++ b/packages/agent-core/test/harness/skill-session.test.ts
@@ -458,20 +458,25 @@ describe('HarnessAPI session skills', () => {
});
await waitForEvent(events, (event) => event.type === 'skill.activated');
- expect(telemetryRecords).toContainEqual({
- event: 'skill_invoked',
- sessionId: created.id,
- properties: {
- skill_name: 'review-flow',
- trigger: 'user-slash',
- },
- });
- expect(telemetryRecords).toContainEqual({
- event: 'flow_invoked',
- sessionId: created.id,
- properties: {
- flow_name: 'review-flow',
- },
+ // The telemetry records are written independently of `skill.activated`, so
+ // the event is not a barrier for them — asserting straight after it raced
+ // and failed intermittently on a loaded machine.
+ await vi.waitFor(() => {
+ expect(telemetryRecords).toContainEqual({
+ event: 'skill_invoked',
+ sessionId: created.id,
+ properties: {
+ skill_name: 'review-flow',
+ trigger: 'user-slash',
+ },
+ });
+ expect(telemetryRecords).toContainEqual({
+ event: 'flow_invoked',
+ sessionId: created.id,
+ properties: {
+ flow_name: 'review-flow',
+ },
+ });
});
});
diff --git a/packages/agent-core/test/session/subagent-batch.test.ts b/packages/agent-core/test/session/subagent-batch.test.ts
index 6905a4c6..5f181fe9 100644
--- a/packages/agent-core/test/session/subagent-batch.test.ts
+++ b/packages/agent-core/test/session/subagent-batch.test.ts
@@ -66,6 +66,211 @@ describe('SubagentBatch scheduling contract', () => {
}
});
+ it('caps live attempts at the concurrency limit and completes all tasks in order', async () => {
+ vi.useFakeTimers();
+ try {
+ const { runBatch, attempts } = createMockBatchRunner({}, 2);
+ const running = runBatch(
+ Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)),
+ { signal },
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(2);
+
+ // Each completion frees a slot for the next queued task; at most two
+ // attempts are ever live at once.
+ attempts[0]!.outcome.resolve({
+ task: attempts[0]!.task,
+ agentId: 'agent-1',
+ status: 'completed',
+ result: 'completed 1',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(3);
+ expect(attempts[2]!.task.data).toBe(3);
+
+ attempts[1]!.outcome.resolve({
+ task: attempts[1]!.task,
+ agentId: 'agent-2',
+ status: 'completed',
+ result: 'completed 2',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(4);
+ expect(attempts[3]!.task.data).toBe(4);
+
+ attempts[2]!.outcome.resolve({
+ task: attempts[2]!.task,
+ agentId: 'agent-3',
+ status: 'completed',
+ result: 'completed 3',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(5);
+ expect(attempts[4]!.task.data).toBe(5);
+
+ attempts[3]!.outcome.resolve({
+ task: attempts[3]!.task,
+ agentId: 'agent-4',
+ status: 'completed',
+ result: 'completed 4',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(5);
+
+ // The initial launch limit of 5 is exhausted, so task 6 starts when
+ // the ramp timer fires with a free slot.
+ await vi.advanceTimersByTimeAsync(700);
+ expect(attempts).toHaveLength(6);
+ expect(attempts[5]!.task.data).toBe(6);
+
+ attempts[4]!.outcome.resolve({
+ task: attempts[4]!.task,
+ agentId: 'agent-5',
+ status: 'completed',
+ result: 'completed 5',
+ });
+ attempts[5]!.outcome.resolve({
+ task: attempts[5]!.task,
+ agentId: 'agent-6',
+ status: 'completed',
+ result: 'completed 6',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+
+ const results = await running;
+ expect(results.map((result) => result.task.data)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(results.every((result) => result.status === 'completed')).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it.each([
+ ['NaN', Number.NaN],
+ ['Infinity', Number.POSITIVE_INFINITY],
+ ])('falls back to the default limit for a %s concurrency limit', async (_label, limit) => {
+ vi.useFakeTimers();
+ try {
+ // NaN survives Math.trunc and Math.max, and `running < NaN` is always
+ // false, so the batch would launch nothing and never finish.
+ const { runBatch, attempts } = createMockBatchRunner({}, limit);
+ const running = runBatch([queuedTask(1), queuedTask(2)], { signal });
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(2);
+
+ for (const [index, attempt] of attempts.entries()) {
+ attempt.outcome.resolve({
+ task: attempt.task,
+ agentId: `agent-${String(index + 1)}`,
+ status: 'completed',
+ result: `completed ${String(index + 1)}`,
+ });
+ }
+ await vi.advanceTimersByTimeAsync(0);
+
+ const results = await running;
+ expect(results.map((result) => result.task.data)).toEqual([1, 2]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('re-arms the launch timer while the concurrency cap blocks the ramp', async () => {
+ vi.useFakeTimers();
+ try {
+ const { runBatch, attempts } = createMockBatchRunner({}, 2);
+ const running = runBatch(
+ Array.from({ length: 6 }, (_, index) => queuedTask(index + 1)),
+ { signal },
+ );
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(2);
+
+ attempts[0]!.outcome.resolve({
+ task: attempts[0]!.task,
+ agentId: 'agent-1',
+ status: 'completed',
+ result: 'completed 1',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(3);
+ expect(attempts[2]!.task.data).toBe(3);
+
+ // The t=0 timer fires while both slots are full. The batch must re-arm
+ // it rather than drop the wakeup; a dropped wakeup is only replaced by
+ // the next completion, which shifts every later launch one timer period
+ // later and strands the last task behind the exhausted initial launch
+ // limit.
+ await vi.advanceTimersByTimeAsync(700);
+ expect(attempts).toHaveLength(3);
+
+ await vi.advanceTimersByTimeAsync(1);
+ attempts[1]!.outcome.resolve({
+ task: attempts[1]!.task,
+ agentId: 'agent-2',
+ status: 'completed',
+ result: 'completed 2',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(4);
+ expect(attempts[3]!.task.data).toBe(4);
+
+ // Second blocked fire at t=1400 (re-armed from t=700).
+ await vi.advanceTimersByTimeAsync(699);
+ expect(attempts).toHaveLength(4);
+
+ await vi.advanceTimersByTimeAsync(1);
+ attempts[2]!.outcome.resolve({
+ task: attempts[2]!.task,
+ agentId: 'agent-3',
+ status: 'completed',
+ result: 'completed 3',
+ });
+ attempts[3]!.outcome.resolve({
+ task: attempts[3]!.task,
+ agentId: 'agent-4',
+ status: 'completed',
+ result: 'completed 4',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(attempts).toHaveLength(5);
+ expect(attempts[4]!.task.data).toBe(5);
+
+ // Task 6 can no longer be started by a completion (the initial launch
+ // limit of 5 is exhausted), so it depends entirely on the re-armed
+ // timer firing after a slot frees.
+ await vi.advanceTimersByTimeAsync(698);
+ expect(attempts).toHaveLength(5);
+
+ await vi.advanceTimersByTimeAsync(1);
+ expect(attempts).toHaveLength(6);
+ expect(attempts[5]!.task.data).toBe(6);
+
+ attempts[4]!.outcome.resolve({
+ task: attempts[4]!.task,
+ agentId: 'agent-5',
+ status: 'completed',
+ result: 'completed 5',
+ });
+ attempts[5]!.outcome.resolve({
+ task: attempts[5]!.task,
+ agentId: 'agent-6',
+ status: 'completed',
+ result: 'completed 6',
+ });
+ await vi.advanceTimersByTimeAsync(0);
+
+ const results = await running;
+ expect(results.map((result) => result.task.data)).toEqual([1, 2, 3, 4, 5, 6]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it('rate-limit phase starts when the first provider rate limit stops the normal ramp', async () => {
vi.useFakeTimers();
try {
@@ -739,6 +944,7 @@ type MockBatchRunnerOptions = {
function createMockBatchRunner(
options: MockBatchRunnerOptions = {},
+ concurrencyLimit?: number,
): {
readonly runBatch: (
tasks: readonly QueuedSubagentTask[],
@@ -809,7 +1015,7 @@ function createMockBatchRunner(
...task,
signal: task.signal ?? runOptions?.signal,
}));
- return new SubagentBatch(host, activeTasks as readonly QueuedSubagentTask[]).run();
+ return new SubagentBatch(host, activeTasks as readonly QueuedSubagentTask[], concurrencyLimit).run();
},
attempts,
};
diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts
index 2e087652..680d3c2c 100644
--- a/packages/agent-core/test/session/subagent-host.test.ts
+++ b/packages/agent-core/test/session/subagent-host.test.ts
@@ -17,6 +17,8 @@ import {
settleSubagentWorktree,
} from '../../src/session/subagent-worktree';
import {
+ MAX_SUBAGENTS_PER_SESSION,
+ MAX_SUBAGENT_SPAWN_DEPTH,
SessionSubagentHost,
type QueuedSubagentTask,
} from '../../src/session/subagent-host';
@@ -689,6 +691,7 @@ describe('SessionSubagentHost', () => {
const host = new SessionSubagentHost(
{
agents: new Map([['main', parent.agent]]),
+ metadata: { agents: {} },
ensureAgentResumed: vi.fn(async () => parent.agent),
createAgent,
} as never,
@@ -715,6 +718,7 @@ describe('SessionSubagentHost', () => {
const host = new SessionSubagentHost(
{
agents: new Map([['main', parent.agent]]),
+ metadata: { agents: {} },
ensureAgentResumed: vi.fn(async () => parent.agent),
createAgent,
} as never,
@@ -734,6 +738,148 @@ describe('SessionSubagentHost', () => {
expect(createAgent).not.toHaveBeenCalled();
});
+ it('rejects a spawn when the session has reached its subagent cap', async () => {
+ const parent = testAgent();
+ parent.configure();
+ const child = testAgent({ type: 'sub' });
+ const metadataAgents = Object.fromEntries(
+ Array.from({ length: MAX_SUBAGENTS_PER_SESSION }, (_, index) => [
+ `agent-${String(index)}`,
+ { type: 'sub' as const, parentAgentId: 'main' },
+ ]),
+ );
+ const session = fakeSession(parent.agent, child.agent, metadataAgents);
+ const host = new SessionSubagentHost(session, 'main');
+
+ await expect(
+ host.spawn({
+ profileName: 'coder',
+ parentToolCallId: 'call_agent',
+ prompt: 'Implement the fix',
+ description: 'Fix bug',
+ runInBackground: false,
+ signal,
+ }),
+ ).rejects.toThrow(String(MAX_SUBAGENTS_PER_SESSION));
+ expect(session.createAgent).not.toHaveBeenCalled();
+ });
+
+ it('rejects a spawn when the owner is already at the nesting depth cap', async () => {
+ const parent = testAgent();
+ parent.configure();
+ const child = testAgent({ type: 'sub' });
+ const session = Object.assign(
+ fakeSession(parent.agent, child.agent, {
+ main: { type: 'main', parentAgentId: null },
+ 'agent-0': { type: 'sub', parentAgentId: 'main' },
+ 'agent-1': { type: 'sub', parentAgentId: 'agent-0' },
+ 'agent-2': { type: 'sub', parentAgentId: 'agent-1' },
+ }),
+ {
+ ensureAgentResumed: vi.fn(async () => parent.agent),
+ },
+ );
+ // 'agent-2' is at depth 3 (main is depth 0), so its child would be at
+ // depth 4 — beyond MAX_SUBAGENT_SPAWN_DEPTH.
+ const host = new SessionSubagentHost(session, 'agent-2');
+
+ await expect(
+ host.spawn({
+ profileName: 'coder',
+ parentToolCallId: 'call_agent',
+ prompt: 'Nest too deep',
+ description: 'Too deep',
+ runInBackground: false,
+ signal,
+ }),
+ ).rejects.toThrow(String(MAX_SUBAGENT_SPAWN_DEPTH));
+ expect(session.createAgent).not.toHaveBeenCalled();
+ });
+
+ it('allows a spawn one nesting level shallower than the depth cap', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const summary =
+ 'Completed the delegated subagent task from the shallower nesting level and returned a detailed technical handoff to the parent agent, so the work continues without repeating any of it. '.repeat(
+ 2,
+ );
+ const child = testAgent({ type: 'sub' });
+ child.mockNextResponse({ type: 'text', text: summary });
+ const session = Object.assign(
+ fakeSession(parent.agent, child.agent, {
+ main: { type: 'main', parentAgentId: null },
+ 'agent-0': { type: 'sub', parentAgentId: 'main' },
+ 'agent-1': { type: 'sub', parentAgentId: 'agent-0' },
+ }),
+ {
+ ensureAgentResumed: vi.fn(async () => parent.agent),
+ },
+ );
+ // 'agent-1' is at depth 2, so its child lands at depth 3 — exactly the
+ // cap, and therefore allowed.
+ const host = new SessionSubagentHost(session, 'agent-1');
+
+ const handle = await host.spawn({
+ profileName: 'coder',
+ parentToolCallId: 'call_agent',
+ prompt: 'Work at the allowed depth',
+ description: 'Allowed depth',
+ runInBackground: false,
+ signal,
+ });
+ await expect(handle.completion).resolves.toMatchObject({ result: summary.trim() });
+ expect(session.createAgent).toHaveBeenCalledTimes(1);
+ });
+
+ it('resume is not blocked by the per-session subagent cap', async () => {
+ const parent = testAgent();
+ parent.configure();
+
+ const child = testAgent({ type: 'sub' });
+ child.configure({ tools: ['Read'] });
+ child.agent.useProfile(
+ profile({ name: 'explore', tools: ['Read'], systemPrompt: 'explore prompt' }),
+ );
+ child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]);
+ child.mockNextResponse({
+ type: 'text',
+ text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.',
+ });
+
+ const otherAgents = Object.fromEntries(
+ Array.from({ length: MAX_SUBAGENTS_PER_SESSION - 1 }, (_, index) => [
+ `agent-${String(index + 1)}`,
+ { type: 'sub' as const, parentAgentId: 'main' },
+ ]),
+ );
+ const session = fakeSession(parent.agent, child.agent, {
+ 'agent-0': { type: 'sub', parentAgentId: 'main' },
+ ...otherAgents,
+ });
+ const host = new SessionSubagentHost(session, 'main');
+
+ const handle = await host.resume('agent-0', {
+ parentToolCallId: 'call_agent',
+ prompt: 'Continue from context',
+ description: 'Continue work',
+ runInBackground: false,
+ signal,
+ });
+
+ expect(handle).toMatchObject({
+ agentId: 'agent-0',
+ profileName: 'explore',
+ resumed: true,
+ });
+ await expect(handle.completion).resolves.toMatchObject({
+ result:
+ 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.',
+ });
+ expect(session.createAgent).not.toHaveBeenCalled();
+ });
+
it('cancels the child turn when the caller signal aborts', async () => {
const parent = testAgent();
parent.configure();
@@ -1022,6 +1168,126 @@ describe('SessionSubagentHost', () => {
expect(child.llmCalls).toHaveLength(1);
});
+ it('passes an output schema to the child turn and returns the structured output', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const outputSchema = {
+ type: 'object',
+ properties: { answer: { type: 'string' } },
+ required: ['answer'],
+ };
+ const child = testAgent({ type: 'sub' });
+ child.mockNextResponse(
+ { type: 'text', text: 'I will return structured output.' },
+ {
+ type: 'function',
+ id: 'call_structured_output',
+ name: 'StructuredOutput',
+ arguments: JSON.stringify({ answer: 'done' }),
+ },
+ );
+ const session = fakeSession(parent.agent, child.agent);
+ const host = new SessionSubagentHost(session, 'main');
+ const promptSpy = vi.spyOn(child.agent.turn, 'prompt');
+
+ const handle = await host.spawn({
+ profileName: 'coder',
+ outputSchema,
+ parentToolCallId: 'call_agent',
+ prompt: 'Return structured output',
+ description: 'Structured task',
+ runInBackground: false,
+ signal,
+ });
+
+ await expect(handle.completion).resolves.toMatchObject({
+ result: JSON.stringify({ answer: 'done' }),
+ });
+ expect(promptSpy).toHaveBeenCalledTimes(1);
+ expect(promptSpy.mock.calls[0]?.[2]).toBe(outputSchema);
+ });
+
+ it('skips the short-summary continuation when an output schema is set', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const outputSchema = { type: 'object', properties: { answer: { type: 'string' } } };
+ const child = testAgent({ type: 'sub' });
+ for (let i = 0; i < 6; i += 1) {
+ child.mockNextResponse({ type: 'text', text: 'short' });
+ }
+ const session = fakeSession(parent.agent, child.agent);
+ const host = new SessionSubagentHost(session, 'main');
+ const promptSpy = vi.spyOn(child.agent.turn, 'prompt');
+
+ const handle = await host.spawn({
+ profileName: 'coder',
+ outputSchema,
+ parentToolCallId: 'call_agent',
+ prompt: 'Return structured output',
+ description: 'Structured task',
+ runInBackground: false,
+ signal,
+ });
+
+ // The model never calls StructuredOutput, so the turn exhausts its
+ // completion reminders and fails with structured_output.max_retries. The
+ // assistant text stays well under SUMMARY_MIN_LENGTH, so a second
+ // turn.prompt call would mean the continuation ran.
+ await expect(handle.completion).rejects.toThrow('structured_output.max_retries');
+ expect(promptSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports structured_output.max_retries as schema_error and other failures as failed', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const child = testAgent({ type: 'sub' });
+ child.configure();
+ for (let i = 0; i < 6; i += 1) {
+ child.mockNextResponse({ type: 'text', text: 'short' });
+ }
+ const session = fakeSession(parent.agent, child.agent);
+ const host = new SessionSubagentHost(session, 'main');
+
+ const schemaErrorResults = await host.runQueued([
+ {
+ ...queuedTask(1),
+ outputSchema: { type: 'object', properties: { answer: { type: 'string' } } },
+ signal,
+ },
+ ]);
+ const schemaErrorResult = schemaErrorResults[0]!;
+
+ expect(schemaErrorResult).toMatchObject({
+ status: 'schema_error',
+ state: 'started',
+ });
+ expect(schemaErrorResult.error).toContain('structured_output.max_retries');
+
+ child.mockNextProviderResponse({
+ parts: [
+ { type: 'think', think: 'The child used its output budget before writing a summary.' },
+ ],
+ finishReason: 'truncated',
+ rawFinishReason: 'length',
+ });
+ const failedResults = await host.runQueued([{ ...queuedTask(2), signal }]);
+ const failedResult = failedResults[0]!;
+
+ expect(failedResult).toMatchObject({
+ status: 'failed',
+ state: 'started',
+ });
+ expect(failedResult.error).toContain(
+ 'Subagent turn failed before completing its final summary',
+ );
+ });
+
it('prepends git context to the prompt for explore subagents', async () => {
vi.mocked(collectGitContext).mockResolvedValueOnce(
'\nWorking directory: /repo\nBranch: main\n',
@@ -1269,6 +1535,56 @@ describe('SessionSubagentHost', () => {
);
});
+ it('runQueued carries a workflow run id to the child launch options', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const child = testAgent({ type: 'sub' });
+ child.configure();
+ const summary =
+ 'Completed the queued dynamic workflow item and returned a detailed technical handoff so the parent can map the result back to the original dynamic workflow input. '.repeat(
+ 2,
+ );
+ child.mockNextResponse({ type: 'text', text: summary });
+
+ const session = fakeSession(parent.agent, child.agent);
+ const host = new SessionSubagentHost(session, 'main');
+ const spawnSpy = vi.spyOn(host, 'spawn');
+
+ await expect(
+ host.runQueued([
+ {
+ ...queuedTask(1),
+ workflowRunId: 'wfr-test-001',
+ workflowName: 'Review files',
+ signal,
+ },
+ ]),
+ ).resolves.toMatchObject([
+ {
+ agentId: 'agent-0',
+ status: 'completed',
+ result: summary.trim(),
+ },
+ ]);
+
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
+ expect(spawnSpy.mock.calls[0]?.[0]?.workflowRunId).toBe('wfr-test-001');
+ expect(spawnSpy.mock.calls[0]?.[0]?.workflowName).toBe('Review files');
+ expect(parent.allEvents).toContainEqual(
+ expect.objectContaining({
+ type: '[rpc]',
+ event: 'subagent.spawned',
+ args: expect.objectContaining({
+ subagentId: 'agent-0',
+ workflowRunId: 'wfr-test-001',
+ workflowName: 'Review files',
+ }),
+ }),
+ );
+ });
+
it('retries a rate-limited child turn without appending the original prompt again', async () => {
const parent = testAgent();
parent.configure();
@@ -1330,6 +1646,65 @@ describe('SessionSubagentHost', () => {
expect(userTextMessages(histories[1] ?? [])).toEqual(['Implement the retry-safe change']);
});
+ it('keeps the output schema when a rate-limited subagent is retried', async () => {
+ const parent = testAgent();
+ parent.configure();
+ parent.newEvents();
+
+ const outputSchema = {
+ type: 'object',
+ properties: { answer: { type: 'string' } },
+ required: ['answer'],
+ };
+ const toolNamesPerCall: string[][] = [];
+ let generateCalls = 0;
+ const generate: GenerateFn = async (
+ _provider,
+ _systemPrompt,
+ tools,
+ _history,
+ callbacks,
+ ) => {
+ toolNamesPerCall.push(tools.map((tool) => tool.name));
+ generateCalls += 1;
+ if (generateCalls === 1) {
+ throw new APIStatusError(429, 'Rate limited', 'req-429');
+ }
+ // Answers in prose instead of calling StructuredOutput.
+ await callbacks?.onMessagePart?.({ type: 'text', text: 'plain prose answer' });
+ return textResult('plain prose answer');
+ };
+ const child = testAgent({
+ generate,
+ initialConfig: { providers: {}, loopControl: { maxRetriesPerStep: 1 } },
+ });
+ child.configure();
+
+ const session = fakeSession(parent.agent, child.agent);
+ const host = new SessionSubagentHost(session, 'main');
+ const retrySpy = vi.spyOn(child.agent.turn, 'retry');
+
+ const options = {
+ profileName: 'coder',
+ outputSchema,
+ parentToolCallId: 'call_agent',
+ prompt: 'Return structured output',
+ description: 'Structured task',
+ runInBackground: false,
+ signal,
+ };
+ const handle = await host.spawn(options);
+ await expect(handle.completion).rejects.toThrow('Rate limited');
+
+ const retryHandle = await host.retry(handle.agentId, options);
+
+ // Dropping the schema here used to let the retried turn answer in prose and
+ // report completed, silently voiding the structured-output contract.
+ await expect(retryHandle.completion).rejects.toThrow('structured_output.max_retries');
+ expect(retrySpy.mock.calls[0]?.[1]).toBe(outputSchema);
+ expect(toolNamesPerCall.at(-1)).toContain('StructuredOutput');
+ });
+
it('realigns a resumed subagent to the parent agent current model', async () => {
const parent = testAgent();
parent.configure();
diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts
index 71120182..13124c83 100644
--- a/packages/agent-core/test/tools/builtin-current.test.ts
+++ b/packages/agent-core/test/tools/builtin-current.test.ts
@@ -8,10 +8,16 @@
import { Readable, type Writable } from 'node:stream';
import type { Kaos, KaosProcess } from '@pythoughts/kaos';
+import type { WorkflowWarningEvent } from '@pythoughts/protocol';
import { describe, expect, it, vi } from 'vitest';
import type { Agent } from '../../src/agent';
import type { DynamicWorkflowMode } from '../../src/agent/dynamic-workflow';
+import {
+ generateWorkflowRunId,
+ isWorkflowRunId,
+} from '../../src/agent/dynamic-workflow/run-id';
+import { resolveWorkflowSizeGuideline } from '../../src/agent/dynamic-workflow/size-guideline';
import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
import type {
QueuedSubagentRunResult,
@@ -41,6 +47,7 @@ import { createBackgroundManager } from '../agent/background/helpers';
import {
DynamicWorkflowTool,
DynamicWorkflowToolInputSchema,
+ isDynamicWorkflowDisabled,
} from '../../src/tools/builtin/collaboration/dynamic-workflow';
const signal = new AbortController().signal;
@@ -315,6 +322,96 @@ describe('current builtin collaboration tools', () => {
expect(result.output).toContain('child result');
});
+ it('DynamicWorkflow ignores empty items instead of rejecting the whole call', async () => {
+ // A model that emits a trailing empty string used to fail argument
+ // validation, which rejects the entire call before the tool ever runs and
+ // costs a full re-send of every prompt.
+ const input = {
+ description: 'Review files',
+ prompt_template: 'Review {{item}}',
+ items: ['src/a.ts', 'src/b.ts', ''],
+ subagent_type: 'explore',
+ };
+ expect(DynamicWorkflowToolInputSchema.safeParse(input).success).toBe(true);
+ expect(
+ DynamicWorkflowToolInputSchema.safeParse({ ...input, items: ['src/a.ts', ' '] }).success,
+ ).toBe(true);
+
+ const host = mockSubagentHost({
+ runQueued: vi.fn().mockResolvedValue([
+ {
+ task: {
+ kind: 'spawn',
+ data: { kind: 'spawn', index: 1, item: 'src/a.ts', prompt: 'Review src/a.ts' },
+ profileName: 'explore',
+ parentToolCallId: 'call_dynamic_workflow',
+ prompt: 'Review src/a.ts',
+ description: 'Review files #1 (explore)',
+ runInBackground: false,
+ },
+ agentId: 'agent-explore-1',
+ status: 'completed',
+ result: 'explore result a',
+ },
+ {
+ task: {
+ kind: 'spawn',
+ data: { kind: 'spawn', index: 2, item: 'src/b.ts', prompt: 'Review src/b.ts' },
+ profileName: 'explore',
+ parentToolCallId: 'call_dynamic_workflow',
+ prompt: 'Review src/b.ts',
+ description: 'Review files #2 (explore)',
+ runInBackground: false,
+ },
+ agentId: 'agent-explore-2',
+ status: 'completed',
+ result: 'explore result b',
+ },
+ ]),
+ });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode());
+
+ // The panel must advertise the two subagents that will actually launch,
+ // not the three entries that were sent.
+ const execution = tool.resolveExecution(input as never);
+ if (execution.isError === true) throw new Error('expected runnable execution');
+ expect(execution.display).toMatchObject({
+ agent_name: 'Dynamic Workflow (2 subagents)',
+ });
+
+ const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+
+ expect(result.isError).not.toBe(true);
+ const queued = host.runQueued.mock.calls[0]?.[0] as Array<{ prompt: string }>;
+ expect(queued.map((task) => task.prompt)).toEqual(['Review src/a.ts', 'Review src/b.ts']);
+ // A quietly shorter workflow must not read as one the model sized right.
+ expect(result.output).toContain('1 empty item was ignored');
+ // The note must not precede the envelope: consumers match the result
+ // document anchored at the start, so a prefix renders a successful run as
+ // an unsupported result.
+ const outputText = typeof result.output === 'string' ? result.output : '';
+ expect(outputText.trimStart().startsWith(' {
+ const host = mockSubagentHost({ runQueued: vi.fn() });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode());
+ const input = {
+ description: 'Review files',
+ items: ['src/a.ts', '', ''],
+ subagent_type: 'explore',
+ };
+
+ const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+
+ expect(result.isError).toBe(true);
+ // Without the second half the caller reads "requires at least 2 items"
+ // while looking at a list that had three.
+ expect(result.output).toContain('requires at least 2 items');
+ expect(result.output).toContain('2 empty items were ignored');
+ expect(host.runQueued).not.toHaveBeenCalled();
+ });
+
it('DynamicWorkflow applies one subagent_type without automatic timeouts', async () => {
const host = mockSubagentHost({
runQueued: vi.fn().mockResolvedValue([
@@ -364,12 +461,15 @@ describe('current builtin collaboration tools', () => {
items: Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`),
}).success,
).toBe(true);
+ // Over the cap now passes argument validation and fails inside the tool
+ // with a readable message. Rejecting at the schema would discard the whole
+ // call -- including the 128 valid prompts -- over one surplus entry.
expect(
DynamicWorkflowToolInputSchema.safeParse({
...input,
items: Array.from({ length: 129 }, (_, index) => `src/${String(index + 1)}.ts`),
}).success,
- ).toBe(false);
+ ).toBe(true);
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
@@ -384,12 +484,18 @@ describe('current builtin collaboration tools', () => {
'resume_agent_ids',
'model',
'effort',
+ 'output_schema',
]);
const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
expect(dynamicWorkflowMode.enter).toHaveBeenCalledWith('tool');
expect(host.runQueued).toHaveBeenCalledTimes(1);
+ const queuedTasks = vi.mocked(host.runQueued).mock.calls[0]![0];
+ const runId = queuedTasks[0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
+ expect(queuedTasks[1]!.workflowRunId).toBe(runId);
+ expect(queuedTasks.every((task: QueuedSubagentTask) => task.workflowName === 'Review files')).toBe(true);
expect(host.runQueued).toHaveBeenCalledWith(
[
{
@@ -402,6 +508,8 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 1,
dynamicWorkflowItem: 'src/a.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Review files',
signal,
},
{
@@ -414,12 +522,14 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 2,
dynamicWorkflowItem: 'src/b.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Review files',
signal,
},
],
);
expect(result.output).toBe([
- '',
+ ``,
'completed: 2',
'explore result a',
'explore result b',
@@ -428,6 +538,250 @@ describe('current builtin collaboration tools', () => {
expect(result.isError).toBeUndefined();
});
+ it('DynamicWorkflow accepts output_schema and passes it to every queued task', async () => {
+ const outputSchema = {
+ type: 'object',
+ properties: { summary: { type: 'string' } },
+ required: ['summary'],
+ };
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ return tasks.map((task) => ({
+ task,
+ agentId: 'agent-1',
+ status: 'completed' as const,
+ result: 'done',
+ }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode());
+ const input = {
+ description: 'Review files',
+ prompt_template: 'Review {{item}}',
+ items: ['src/a.ts', 'src/b.ts'],
+ output_schema: outputSchema,
+ };
+
+ expect(DynamicWorkflowToolInputSchema.safeParse(input).success).toBe(true);
+ expect(
+ DynamicWorkflowToolInputSchema.safeParse({ ...input, output_schema: { type: 'string' } })
+ .success,
+ ).toBe(true);
+
+ const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+
+ expect(result.isError).toBeUndefined();
+ expect(runQueued).toHaveBeenCalledTimes(1);
+ const queuedTasks = vi.mocked(runQueued).mock.calls[0]![0];
+ expect(queuedTasks).toHaveLength(2);
+ expect(queuedTasks.every((task) => task.outputSchema === outputSchema)).toBe(true);
+ });
+
+ it('DynamicWorkflow renders a schema_error child alongside the other outcomes', async () => {
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ // One outcome per item, positionally: a schema miss, a clean run, and
+ // an ordinary failure — so the render covers all three side by side.
+ const outcomes = [
+ {
+ agentId: 'agent-schema-error',
+ status: 'schema_error' as const,
+ error:
+ '[structured_output.max_retries] Failed to provide valid structured output after the maximum number of retries.',
+ },
+ {
+ agentId: 'agent-clean',
+ status: 'completed' as const,
+ result: 'clean result',
+ },
+ {
+ agentId: 'agent-timed-out',
+ status: 'failed' as const,
+ error: 'Agent timed out after 30s.',
+ },
+ ];
+ return tasks.map((task, index) => ({ task, ...outcomes[index]! }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode());
+
+ const result = await executeTool(
+ tool,
+ context({
+ description: 'Review files',
+ items: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
+ }),
+ );
+
+ expect(result.output).toContain(
+ '[structured_output.max_retries] Failed to provide valid structured output after the maximum number of retries.',
+ );
+ expect(result.output).toContain(
+ 'clean result',
+ );
+ expect(result.output).toContain(
+ 'Agent timed out after 30s.',
+ );
+ expect(result.output).toContain('completed: 1, failed: 1, schema_error: 1');
+ expect(result.isError).toBeUndefined();
+ });
+
+ it('DynamicWorkflow emits exactly one workflow.warning above the size guideline and still runs', async () => {
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ return tasks.map((task) => ({
+ task,
+ agentId: 'agent-1',
+ status: 'completed' as const,
+ result: 'done',
+ }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const emitEvent = vi.fn<(event: WorkflowWarningEvent) => void>();
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'small', emitEvent);
+
+ const result = await executeTool(
+ tool,
+ context({
+ description: 'Review files',
+ items: Array.from({ length: 6 }, (_, index) => `src/${String(index + 1)}.ts`),
+ }),
+ );
+
+ expect(emitEvent).toHaveBeenCalledTimes(1);
+ expect(host.runQueued).toHaveBeenCalledTimes(1);
+ const warning = emitEvent.mock.calls[0]![0];
+ expect(isWorkflowRunId(warning.workflowRunId)).toBe(true);
+ expect(warning).toMatchObject({
+ type: 'workflow.warning',
+ parentToolCallId: 'call_1',
+ agentCount: 6,
+ threshold: 5,
+ });
+ expect(warning.message).toContain('6');
+ expect(warning.message).toContain('5');
+ expect(result.isError).toBeUndefined();
+ });
+
+ it('DynamicWorkflow emits exactly one workflow.warning above the unrestricted fallback threshold', async () => {
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ return tasks.map((task) => ({
+ task,
+ agentId: 'agent-1',
+ status: 'completed' as const,
+ result: 'done',
+ }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const emitEvent = vi.fn<(event: WorkflowWarningEvent) => void>();
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'unrestricted', emitEvent);
+
+ const result = await executeTool(
+ tool,
+ context({
+ description: 'Review files',
+ items: Array.from({ length: 26 }, (_, index) => `src/${String(index + 1)}.ts`),
+ }),
+ );
+
+ expect(emitEvent).toHaveBeenCalledTimes(1);
+ expect(host.runQueued).toHaveBeenCalledTimes(1);
+ const warning = emitEvent.mock.calls[0]![0];
+ expect(isWorkflowRunId(warning.workflowRunId)).toBe(true);
+ expect(warning).toMatchObject({
+ type: 'workflow.warning',
+ parentToolCallId: 'call_1',
+ agentCount: 26,
+ threshold: 25,
+ });
+ expect(warning.message).toContain('26');
+ expect(warning.message).toContain('25');
+ expect(result.isError).toBeUndefined();
+ });
+
+ it('DynamicWorkflow emits no warning at or below the size guideline', async () => {
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ return tasks.map((task) => ({
+ task,
+ agentId: 'agent-1',
+ status: 'completed' as const,
+ result: 'done',
+ }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const emitEvent = vi.fn<(event: WorkflowWarningEvent) => void>();
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'small', emitEvent);
+
+ const result = await executeTool(
+ tool,
+ context({
+ description: 'Review files',
+ items: Array.from({ length: 5 }, (_, index) => `src/${String(index + 1)}.ts`),
+ }),
+ );
+
+ expect(emitEvent).not.toHaveBeenCalled();
+ expect(host.runQueued).toHaveBeenCalledTimes(1);
+ expect(result.isError).toBeUndefined();
+ });
+
+ it('DynamicWorkflow above the size guideline without an emitter does not throw', async () => {
+ const runQueued = vi.fn(
+ async (
+ tasks: readonly QueuedSubagentTask[],
+ ): Promise>> => {
+ return tasks.map((task) => ({
+ task,
+ agentId: 'agent-1',
+ status: 'completed' as const,
+ result: 'done',
+ }));
+ },
+ );
+ const host = mockSubagentHost({
+ runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
+ });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'small');
+
+ const result = await executeTool(
+ tool,
+ context({
+ description: 'Review files',
+ items: Array.from({ length: 6 }, (_, index) => `src/${String(index + 1)}.ts`),
+ }),
+ );
+
+ expect(host.runQueued).toHaveBeenCalledTimes(1);
+ expect(result.isError).toBeUndefined();
+ });
+
it('DynamicWorkflow escapes XML-sensitive agent, item, and result text', async () => {
const runQueued = vi.fn(
async (
@@ -510,6 +864,24 @@ describe('current builtin collaboration tools', () => {
expect(execution.matchesRule).toBeUndefined();
});
+ it('DynamicWorkflow accepts a full item list carrying a blank entry', async () => {
+ // 128 real prompts plus one blank used to trip the schema's raw-length cap,
+ // which rejected the entire call -- the very hole the blank-item handling
+ // exists to close, reopened at the boundary.
+ const items = [...Array.from({ length: 128 }, (_, index) => `src/${String(index + 1)}.ts`), ''];
+ const input = { description: 'Review files', prompt_template: 'Review {{item}}', items };
+
+ expect(DynamicWorkflowToolInputSchema.safeParse(input).success).toBe(true);
+
+ const host = mockSubagentHost({ runQueued: vi.fn().mockResolvedValue([]) });
+ const tool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode());
+ const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+
+ expect(result.isError).not.toBe(true);
+ expect(host.runQueued).toHaveBeenCalledTimes(1);
+ expect((host.runQueued.mock.calls[0]?.[0] as unknown[]).length).toBe(128);
+ });
+
it('DynamicWorkflow rejects more than 128 subagents at execution time', async () => {
const host = mockSubagentHost({ runQueued: vi.fn() });
const dynamicWorkflowMode = mockDynamicWorkflowMode();
@@ -614,6 +986,8 @@ describe('current builtin collaboration tools', () => {
).toBe(true);
const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(host.runQueued).toHaveBeenCalledTimes(1);
expect(host.runQueued).toHaveBeenCalledWith(
@@ -634,6 +1008,8 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 1,
dynamicWorkflowItem: 'src/old-a.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Finish review',
resumeAgentId: 'agent-old-1',
signal,
},
@@ -653,6 +1029,8 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 2,
dynamicWorkflowItem: 'src/old-b.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Finish review',
resumeAgentId: 'agent-old-2',
signal,
},
@@ -671,12 +1049,14 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 3,
dynamicWorkflowItem: 'src/new.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Finish review',
signal,
},
],
);
expect(result.output).toBe([
- '',
+ ``,
'completed: 3',
'result 1',
'result 2',
@@ -717,6 +1097,8 @@ describe('current builtin collaboration tools', () => {
expect(DynamicWorkflowToolInputSchema.safeParse(input).success).toBe(true);
const result = await executeTool(tool, context(input, 'call_dynamic_workflow'));
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(host.runQueued).toHaveBeenCalledTimes(1);
expect(host.runQueued).toHaveBeenCalledWith([
@@ -736,12 +1118,14 @@ describe('current builtin collaboration tools', () => {
dynamicWorkflowIndex: 1,
dynamicWorkflowItem: 'src/old-a.ts',
runInBackground: false,
+ workflowRunId: runId,
+ workflowName: 'Resume review',
resumeAgentId: 'agent-old-1',
signal,
},
]);
expect(result.output).toBe([
- '',
+ ``,
'completed: 1',
'resumed result',
'',
@@ -797,8 +1181,10 @@ describe('current builtin collaboration tools', () => {
),
);
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(result.output).toBe([
- '',
+ ``,
'completed: 1, failed: 1',
'Call DynamicWorkflow with resume_agent_ids using the agent_id values in this result to continue unfinished work.',
'imports are stable',
@@ -855,8 +1241,10 @@ describe('current builtin collaboration tools', () => {
),
);
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(result.output).toBe([
- '',
+ ``,
'failed: 2',
'Agent did not start.',
'Agent also did not start.',
@@ -908,8 +1296,10 @@ describe('current builtin collaboration tools', () => {
}),
);
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(result.output).toBe([
- '',
+ ``,
'completed: 1, failed: 1',
'imports are stable',
'Agent did not start.',
@@ -980,8 +1370,10 @@ describe('current builtin collaboration tools', () => {
),
);
+ const runId = vi.mocked(host.runQueued).mock.calls[0]![0]![0]!.workflowRunId!;
+ expect(isWorkflowRunId(runId)).toBe(true);
expect(result.output).toBe([
- '',
+ ``,
'completed: 1, aborted: 2',
'Call DynamicWorkflow with resume_agent_ids using the agent_id values in this result to continue unfinished work.',
'imports are stable',
@@ -1015,6 +1407,84 @@ describe('current builtin collaboration tools', () => {
});
});
+describe('isDynamicWorkflowDisabled', () => {
+ it('resolves the switch from config and env with env winning', () => {
+ expect(isDynamicWorkflowDisabled(undefined, {})).toBe(false);
+ expect(isDynamicWorkflowDisabled({ disableWorkflows: true }, {})).toBe(true);
+ expect(isDynamicWorkflowDisabled(undefined, { PYTHINKER_CODE_DISABLE_WORKFLOWS: '1' })).toBe(true);
+ expect(
+ isDynamicWorkflowDisabled({ disableWorkflows: true }, { PYTHINKER_CODE_DISABLE_WORKFLOWS: 'false' }),
+ ).toBe(false);
+ expect(
+ isDynamicWorkflowDisabled({ disableWorkflows: true }, { PYTHINKER_CODE_DISABLE_WORKFLOWS: 'maybe' }),
+ ).toBe(true);
+ });
+});
+
+describe('workflowSizeGuideline', () => {
+ it('resolves the guideline from config and env with env winning', () => {
+ expect(resolveWorkflowSizeGuideline(undefined, {})).toBe('medium');
+ expect(resolveWorkflowSizeGuideline({ workflowSizeGuideline: 'small' }, {})).toBe('small');
+ expect(
+ resolveWorkflowSizeGuideline(undefined, { PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE: 'large' }),
+ ).toBe('large');
+ expect(
+ resolveWorkflowSizeGuideline(
+ { workflowSizeGuideline: 'small' },
+ { PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE: 'LARGE' },
+ ),
+ ).toBe('large');
+ expect(
+ resolveWorkflowSizeGuideline(
+ { workflowSizeGuideline: 'small' },
+ { PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE: 'huge' },
+ ),
+ ).toBe('small');
+ });
+
+ it('appends the advisory note to DynamicWorkflowTool descriptions unless unrestricted', () => {
+ const host = mockSubagentHost({});
+
+ const smallTool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'small');
+ expect(smallTool.description).toContain('about 5 subagents');
+
+ const unrestrictedTool = new DynamicWorkflowTool(host, mockDynamicWorkflowMode(), 'unrestricted');
+ expect(unrestrictedTool.description).toContain('DynamicWorkflow supports up to 128 subagents');
+ expect(unrestrictedTool.description).not.toContain('Workflow size guideline:');
+ });
+});
+
+describe('workflow run ids', () => {
+ it('generates ids that satisfy the validator', () => {
+ expect(isWorkflowRunId(generateWorkflowRunId())).toBe(true);
+ });
+
+ it('generates distinct ids on consecutive calls', () => {
+ expect(generateWorkflowRunId()).not.toBe(generateWorkflowRunId());
+ });
+
+ it.each([
+ { name: 'an empty string', value: '' },
+ { name: 'a path traversal', value: 'wfr-../etc/passwd' },
+ { name: 'a value containing a slash', value: 'wfr-a/b-c' },
+ { name: 'uppercase letters', value: 'WFR-ABC-DEF' },
+ { name: 'a trailing space', value: 'wfr-abc-def ' },
+ { name: 'a plain string without the wfr- prefix', value: 'plain-string' },
+ { name: 'an overlong string', value: 'x'.repeat(500) },
+ // Structurally valid, so this reaches the component-length cap instead of
+ // failing earlier on a missing prefix.
+ { name: 'an overlong first component', value: `wfr-${'a'.repeat(33)}-def` },
+ { name: 'an overlong second component', value: `wfr-abc-${'a'.repeat(17)}` },
+ // A run id becomes a filename segment, so anything that could terminate or
+ // split a path has to be rejected outright rather than merely unexpected.
+ { name: 'a trailing newline', value: `wfr-abc-def${String.fromCodePoint(10)}` },
+ { name: 'an embedded NUL', value: `wfr-abc-def${String.fromCodePoint(0)}` },
+ { name: 'a NUL followed by traversal', value: `wfr-abc-def${String.fromCodePoint(0)}/../x` },
+ ])('rejects $name', ({ value }) => {
+ expect(isWorkflowRunId(value)).toBe(false);
+ });
+});
+
describe('current builtin background tool schemas', () => {
it('background task schemas and manager-backed tools are covered', () => {
const manager = createBackgroundManager().manager;
diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts
index b55dc889..417be817 100644
--- a/packages/node-sdk/src/catalog.ts
+++ b/packages/node-sdk/src/catalog.ts
@@ -38,7 +38,13 @@ export async function fetchCatalog(
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
throw new Error(`Unexpected catalog response from ${url}.`);
}
- return payload as Catalog;
+ // Drop entries that are not objects instead of casting the payload whole. A
+ // `null` entry reaches `catalogConnectionWire` and throws well past the
+ // caller's fallback, which turns one malformed record into a dead login.
+ const entries = Object.entries(payload).filter(
+ ([, entry]) => typeof entry === 'object' && entry !== null && !Array.isArray(entry),
+ );
+ return Object.fromEntries(entries) as Catalog;
}
function capabilityToStrings(
@@ -82,6 +88,8 @@ export interface ApplyCatalogProviderOptions {
readonly models: readonly CatalogModel[];
readonly selectedModelId: string;
readonly thinking: boolean;
+ /** The effort level the user picked; only the on/off bit persists without it. */
+ readonly effort?: string;
}
/**
@@ -134,6 +142,13 @@ export function applyCatalogProvider(
const defaultModel = `${options.providerId}/${options.selectedModelId}`;
config.defaultModel = defaultModel;
config.defaultThinking = options.thinking;
+ // defaultThinking is a boolean, so without this the picked level is lost and
+ // the session reopens at 'high' regardless of what the user chose. 'off' is
+ // written too rather than skipped: `setConfig` deep-merges and cannot delete a
+ // key, so skipping it would leave a previous login's effort on disk.
+ if (options.effort !== undefined) {
+ config.thinking = { ...config.thinking, effort: options.effort };
+ }
return { defaultModel };
}
diff --git a/packages/node-sdk/src/error-format.ts b/packages/node-sdk/src/error-format.ts
new file mode 100644
index 00000000..60f63b55
--- /dev/null
+++ b/packages/node-sdk/src/error-format.ts
@@ -0,0 +1,43 @@
+import {
+ isPythinkerError,
+ type PythinkerErrorPayload,
+} from '@pythoughts/agent-core';
+
+export function formatErrorMessage(error: unknown): string {
+ if (isPythinkerError(error)) {
+ return formatErrorPayload({
+ code: error.code,
+ message: error.message,
+ details: error.details,
+ });
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+export function formatErrorPayload(
+ error: Pick,
+): string {
+ const filteredMessage = formatProviderFilteredMessage(error.details);
+ if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`;
+ return `[${error.code}] ${error.message}`;
+}
+
+function formatProviderFilteredMessage(
+ details: Record | undefined,
+): string | undefined {
+ const finishReason = stringDetail(details, 'finishReason');
+ const rawFinishReason = stringDetail(details, 'rawFinishReason');
+ if (finishReason !== 'filtered' && rawFinishReason !== 'content_filter') return undefined;
+
+ const normalizedFinishReason = finishReason ?? 'filtered';
+ const raw = rawFinishReason === undefined ? '' : `, rawFinishReason=${rawFinishReason}`;
+ return `Provider filtered the response before visible output (finishReason=${normalizedFinishReason}${raw}).`;
+}
+
+function stringDetail(
+ details: Record | undefined,
+ key: string,
+): string | undefined {
+ const value = details?.[key];
+ return typeof value === 'string' ? value : undefined;
+}
diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts
index cb2e3b62..71a3931e 100644
--- a/packages/node-sdk/src/index.ts
+++ b/packages/node-sdk/src/index.ts
@@ -48,6 +48,36 @@ export type {
export { buildSkillSlashCommands, isUserActivatableSkill } from '#/skill-commands';
export type { SkillSlashCommand, SkillSlashCommands } from '#/skill-commands';
+// Multi-provider login flows behind the LoginUi port, shared by every surface
+// (CLI, TUI, VS Code extension).
+export { formatErrorMessage, formatErrorPayload } from '#/error-format';
+export {
+ buildPlatformOptions,
+ KIMI_CODE_PLATFORM_ID,
+ resolvePlatformOption,
+ type PlatformOption,
+} from '#/login/platform-options';
+export {
+ CATALOG_PLATFORM_VALUE_PREFIX,
+ catalogProviderIdFromPlatformValue,
+} from '#/login/platform-values';
+export { connectCatalogProvider, runLogin } from '#/login/flows';
+export { managedModelToAlias } from '#/login/model-alias';
+export {
+ CANONICAL_EFFORT_ORDER,
+ coerceEffortForModel,
+ DEFAULT_SUPPORTED_EFFORTS,
+ effortLevelsForModel,
+ thinkingAvailability,
+ type ThinkingAvailability,
+} from '#/thinking-levels';
+export type {
+ ApiKeyPromptOptions,
+ LoginProgressSpinnerHandle,
+ LoginUi,
+ PlatformSelection,
+} from '#/login/types';
+
export {
ErrorCodes,
PythinkerError,
diff --git a/packages/node-sdk/src/login/flows.ts b/packages/node-sdk/src/login/flows.ts
new file mode 100644
index 00000000..d49db382
--- /dev/null
+++ b/packages/node-sdk/src/login/flows.ts
@@ -0,0 +1,432 @@
+import {
+ applyOpenAICodexOAuthConfig,
+ applyOpenPlatformConfig,
+ fetchOpenAICodexModels,
+ fetchOpenPlatformModels,
+ filterModelsByPrefix,
+ getOpenPlatformById,
+ OPENAI_CODEX_OAUTH_PLATFORM_ID,
+ OPENAI_CODEX_PROVIDER_ID,
+ OpenAICodexApiError,
+ OpenPlatformApiError,
+ runOpenAICodexOAuthFlow,
+ type ManagedKimiCodeModelInfo,
+ type ManagedKimiConfigShape,
+ KIMI_CODE_PROVIDER_NAME as DEFAULT_OAUTH_PROVIDER_NAME,
+ type OpenPlatformDefinition,
+} from '@pythoughts/pythinker-code-oauth';
+import { log } from '@pythoughts/agent-core';
+import {
+ applyCatalogProvider,
+ catalogBaseUrl,
+ catalogConnectionWire,
+ catalogProviderModels,
+ DEFAULT_CATALOG_URL,
+ fetchCatalog,
+ type CatalogProviderEntry,
+} from '#/catalog';
+
+import { formatErrorMessage } from '../error-format';
+import { KIMI_CODE_PLATFORM_ID } from './platform-options';
+import { catalogProviderIdFromPlatformValue } from './platform-values';
+import type { LoginProgressSpinnerHandle, LoginUi } from './types';
+
+// ---------------------------------------------------------------------------
+// Login flows behind the LoginUi port (shared with non-TUI renderers)
+// ---------------------------------------------------------------------------
+
+
+/**
+ * Run the provider picker and the selected provider's login flow.
+ *
+ * Resolves `true` only when credentials were written to config. Callers such as
+ * `pythinker login` use that for their exit code, so it must never be inferred
+ * from a side effect like telemetry: every early return here is a user
+ * cancellation or a failure the flow already reported.
+ */
+export async function runLogin(ui: LoginUi): Promise {
+ const selection = await ui.promptPlatformSelection();
+ if (selection === undefined) return false;
+ const { platformId, catalog } = selection;
+
+ const catalogProviderId = catalogProviderIdFromPlatformValue(platformId);
+ if (catalogProviderId !== undefined) {
+ return connectCatalogProvider(ui, catalogProviderId, catalog[catalogProviderId]);
+ }
+
+ if (platformId === KIMI_CODE_PLATFORM_ID) {
+ return handlePythinkerCodeOAuthLogin(ui);
+ }
+
+ if (platformId === OPENAI_CODEX_OAUTH_PLATFORM_ID) {
+ return handleOpenAICodexOAuthLogin(ui);
+ }
+
+ const platform = getOpenPlatformById(platformId);
+ if (platform === undefined) return false;
+
+ if (platform.catalogProviderId !== undefined) {
+ return connectCatalogProvider(
+ ui,
+ platform.catalogProviderId,
+ catalog[platform.catalogProviderId],
+ platform.name,
+ );
+ }
+
+ return handleOpenPlatformLogin(ui, platform);
+}
+
+async function handlePythinkerCodeOAuthLogin(ui: LoginUi): Promise {
+ const status = await ui.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
+ const alreadyLoggedIn = status.providers.some(
+ (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken,
+ );
+
+ let spinner: LoginProgressSpinnerHandle | undefined;
+ const controller = new AbortController();
+ const cancelLogin = (): void => {
+ controller.abort();
+ };
+ ui.cancelInFlight = cancelLogin;
+ try {
+ await ui.harness.auth.login(DEFAULT_OAUTH_PROVIDER_NAME, {
+ signal: controller.signal,
+ onDeviceCode: (data) => {
+ spinner = ui.showLoginAuthorizationPrompt(data);
+ },
+ });
+ spinner?.stop({ ok: true, label: 'Logged in.' });
+ spinner = undefined;
+ try {
+ await ui.refreshConfigAfterLogin();
+ } catch (refreshError) {
+ const message = formatErrorMessage(refreshError);
+ ui.showError(`Authentication successful, but failed to refresh config: ${message}`);
+ return false;
+ }
+ ui.track('login', {
+ provider: DEFAULT_OAUTH_PROVIDER_NAME,
+ already_logged_in: alreadyLoggedIn,
+ });
+ if (alreadyLoggedIn) {
+ ui.showStatus('Already logged in. Model configuration refreshed.');
+ }
+ return true;
+ } catch (error) {
+ const cancelled = controller.signal.aborted;
+ spinner?.stop({
+ ok: false,
+ label: cancelled ? 'Login cancelled.' : 'Login failed.',
+ });
+ spinner = undefined;
+ if (cancelled) return false;
+ log.warn('login failed', {
+ providerName: DEFAULT_OAUTH_PROVIDER_NAME,
+ alreadyLoggedIn,
+ sessionId: ui.sessionId,
+ error,
+ });
+ const message = formatErrorMessage(error);
+ ui.showError(`Login failed: ${message}`);
+ return false;
+ } finally {
+ if (ui.cancelInFlight === cancelLogin) {
+ ui.cancelInFlight = undefined;
+ }
+ }
+}
+
+async function handleOpenPlatformLogin(
+ ui: LoginUi,
+ platform: OpenPlatformDefinition,
+): Promise {
+ const platformName = platform.name;
+ const subtitleLines = [
+ `${'base_url'.padEnd(12)}${platform.baseUrl}`,
+ `${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`,
+ ];
+ const apiKey = await ui.promptApiKey(platformName, subtitleLines);
+ if (apiKey === undefined) return false;
+
+ const controller = new AbortController();
+ const cancelLogin = (): void => {
+ controller.abort();
+ };
+ ui.cancelInFlight = cancelLogin;
+
+ let models: ManagedKimiCodeModelInfo[];
+ try {
+ models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal);
+ models = filterModelsByPrefix(models, platform);
+ } catch (error) {
+ if (controller.signal.aborted) return false;
+ const msg = formatErrorMessage(error);
+ ui.showError(`Failed to verify API key: ${msg}`);
+ if (
+ error instanceof OpenPlatformApiError &&
+ error.status === 401
+ ) {
+ ui.showStatus(
+ 'Hint: If your API key was obtained from Pythinker OAuth, please select "Pythinker (OAuth)" instead.',
+ );
+ }
+ return false;
+ } finally {
+ if (ui.cancelInFlight === cancelLogin) {
+ ui.cancelInFlight = undefined;
+ }
+ }
+
+ if (models.length === 0) {
+ ui.showError('No models available for this platform.');
+ return false;
+ }
+
+ const selection = await ui.promptModelSelectionForOpenPlatform(models, platform);
+ if (selection === undefined) return false;
+
+ const existingConfig = await ui.harness.getConfig();
+ if (existingConfig.providers[platform.id] !== undefined) {
+ await ui.harness.removeProvider(platform.id);
+ }
+
+ const config = await ui.harness.getConfig();
+ applyOpenPlatformConfig(config as ManagedKimiConfigShape, {
+ platform,
+ models,
+ selectedModel: selection.model,
+ thinking: selection.effort !== 'off',
+ effort: selection.effort,
+ apiKey,
+ });
+
+ await ui.harness.setConfig({
+ providers: config.providers,
+ models: config.models,
+ defaultModel: config.defaultModel,
+ defaultThinking: config.defaultThinking,
+ // `applyOpenPlatformConfig` writes the picked effort here. Leaving it out of
+ // the patch dropped it, so only the on/off bit ever reached disk.
+ thinking: config.thinking,
+ });
+
+ await ui.refreshConfigAfterLogin();
+ ui.track('login', { provider: platform.id, method: 'api_key' });
+ ui.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`);
+ return true;
+}
+
+export async function connectCatalogProvider(
+ ui: LoginUi,
+ providerId: string,
+ selectedCatalogEntry?: CatalogProviderEntry,
+ displayName?: string,
+): Promise {
+ let catalogEntry = selectedCatalogEntry;
+ if (catalogEntry === undefined) {
+ const controller = new AbortController();
+ const cancelLogin = (): void => {
+ controller.abort();
+ };
+ ui.cancelInFlight = cancelLogin;
+ try {
+ const catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal);
+ catalogEntry = catalog[providerId];
+ } catch (error) {
+ if (controller.signal.aborted) return false;
+ ui.showError(`Failed to load model catalog: ${formatErrorMessage(error)}`);
+ return false;
+ } finally {
+ if (ui.cancelInFlight === cancelLogin) {
+ ui.cancelInFlight = undefined;
+ }
+ }
+ }
+
+ if (catalogEntry === undefined) {
+ ui.showError(`Catalog provider "${providerId}" was not found.`);
+ return false;
+ }
+ const wire = catalogConnectionWire(catalogEntry);
+ if (wire === undefined) {
+ ui.showError(`Catalog provider "${providerId}" is not supported for login.`);
+ return false;
+ }
+
+ const baseUrl = catalogBaseUrl(catalogEntry, wire);
+ const platformName = displayName ?? catalogEntry.name ?? providerId;
+
+ const apiKeyEnvVar = catalogEntry.env?.[0]?.trim();
+ const envVarHasValue =
+ apiKeyEnvVar !== undefined &&
+ apiKeyEnvVar.length > 0 &&
+ (process.env[apiKeyEnvVar]?.trim().length ?? 0) > 0;
+ let apiKey: string | undefined;
+ if (!envVarHasValue) {
+ const subtitleLines = [
+ ...(baseUrl === undefined ? [] : [`${'base_url'.padEnd(12)}${baseUrl}`]),
+ `${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`,
+ ];
+ apiKey = await ui.promptApiKey(platformName, subtitleLines);
+ if (apiKey === undefined) return false;
+ }
+
+ const models = catalogProviderModels(catalogEntry);
+ if (models.length === 0) {
+ ui.showError('No models available for this platform.');
+ return false;
+ }
+
+ const selection = await ui.promptModelSelectionForCatalog(providerId, models);
+ if (selection === undefined) return false;
+
+ const existingConfig = await ui.harness.getConfig();
+ if (existingConfig.providers[providerId] !== undefined) {
+ await ui.harness.removeProvider(providerId);
+ }
+
+ const config = await ui.harness.getConfig();
+ applyCatalogProvider(config, {
+ providerId,
+ catalogUrl: DEFAULT_CATALOG_URL,
+ wire,
+ baseUrl,
+ apiKey,
+ apiKeyEnvVar: envVarHasValue ? apiKeyEnvVar : undefined,
+ models,
+ selectedModelId: selection.model.id,
+ thinking: selection.effort !== 'off',
+ effort: selection.effort,
+ });
+
+ await ui.harness.setConfig({
+ providers: config.providers,
+ models: config.models,
+ defaultModel: config.defaultModel,
+ defaultThinking: config.defaultThinking,
+ // `applyCatalogProvider` writes the picked effort here. Leaving it out of
+ // the patch dropped it, so only the on/off bit ever reached disk.
+ thinking: config.thinking,
+ });
+
+ await ui.refreshConfigAfterLogin();
+ ui.track('login', { provider: providerId, method: envVarHasValue ? 'api_key_env' : 'api_key' });
+ ui.showStatus(`Setup complete: ${platformName} · ${selection.model.id}`);
+ return true;
+}
+
+async function handleOpenAICodexOAuthLogin(ui: LoginUi): Promise {
+ const controller = new AbortController();
+ const cancelLogin = (): void => {
+ controller.abort();
+ };
+ ui.cancelInFlight = cancelLogin;
+ // Stay armed for the whole flow: the model fetch and the model picker
+ // below are cancellable too, and disarming here left SIGINT with
+ // nothing to abort.
+ try {
+
+ ui.showStatus('Opening browser for OpenAI Codex sign-in…');
+
+ let tokens;
+ try {
+ tokens = await runOpenAICodexOAuthFlow({
+ signal: controller.signal,
+ openBrowser: (url) => {
+ ui.showStatus('Opening browser for OpenAI Codex sign-in…');
+ ui.openBrowser(url);
+ },
+ onManualInput: async () =>
+ ui.promptApiKey(
+ 'OpenAI Codex (OAuth)',
+ [
+ 'Sign in with your ChatGPT account in the browser.',
+ 'If the browser callback fails, paste the full redirect URL here.',
+ ],
+ {
+ title: 'Paste OpenAI Codex redirect URL',
+ secret: false,
+ emptyMessage: 'Redirect URL cannot be empty.',
+ },
+ ),
+ });
+ } catch (error) {
+ if (controller.signal.aborted) {
+ ui.showStatus('OpenAI Codex login cancelled.');
+ return false;
+ }
+ ui.showError(`OpenAI Codex login failed: ${formatErrorMessage(error)}`);
+ return false;
+ }
+
+ let models: ManagedKimiCodeModelInfo[];
+ try {
+ models = await fetchOpenAICodexModels({
+ accessToken: tokens.accessToken,
+ accountId: tokens.accountId,
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if (controller.signal.aborted) return false;
+ ui.showError(`Failed to list OpenAI Codex models: ${formatErrorMessage(error)}`);
+ if (error instanceof OpenAICodexApiError && error.status === 401) {
+ ui.showStatus('Hint: Sign in again with /login if your OpenAI Codex session expired.');
+ }
+ return false;
+ }
+
+ if (models.length === 0) {
+ ui.showError('No models available for OpenAI Codex.');
+ return false;
+ }
+
+ const codexPlatform: OpenPlatformDefinition = {
+ id: OPENAI_CODEX_PROVIDER_ID,
+ name: 'OpenAI Codex (OAuth)',
+ defaultContextLength: 256_000,
+ };
+ const selection = await ui.promptModelSelectionForOpenPlatform(models, codexPlatform);
+ if (selection === undefined) return false;
+ // Ctrl-C while the picker was open aborts the flow; the picker may still
+ // resolve with a value, and an aborted login must not write credentials.
+ if (controller.signal.aborted) return false;
+
+ // Drop the previous provider only once the replacement is certain. Removing
+ // it earlier loses the user's working config on any of the early returns
+ // above — most easily by cancelling the model picker. Matches the ordering
+ // in handleOpenPlatformLogin and connectCatalogProvider.
+ const existingConfig = await ui.harness.getConfig();
+ if (existingConfig.providers[OPENAI_CODEX_PROVIDER_ID] !== undefined) {
+ await ui.harness.removeProvider(OPENAI_CODEX_PROVIDER_ID);
+ }
+
+ const config = await ui.harness.getConfig();
+ applyOpenAICodexOAuthConfig(config as ManagedKimiConfigShape, {
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken,
+ accountId: tokens.accountId,
+ models,
+ selectedModel: selection.model,
+ thinking: selection.effort !== 'off',
+ effort: selection.effort,
+ });
+
+ await ui.harness.setConfig({
+ providers: config.providers,
+ models: config.models,
+ defaultModel: config.defaultModel,
+ defaultThinking: config.defaultThinking,
+ thinking: config.thinking,
+ });
+
+ await ui.refreshConfigAfterLogin();
+ ui.track('login', { provider: OPENAI_CODEX_OAUTH_PLATFORM_ID, method: 'oauth' });
+ ui.showStatus(`Logged in to OpenAI Codex · ${selection.model.id}`);
+ return true;
+ } finally {
+ if (ui.cancelInFlight === cancelLogin) {
+ ui.cancelInFlight = undefined;
+ }
+ }
+}
diff --git a/packages/node-sdk/src/login/model-alias.ts b/packages/node-sdk/src/login/model-alias.ts
new file mode 100644
index 00000000..c92be162
--- /dev/null
+++ b/packages/node-sdk/src/login/model-alias.ts
@@ -0,0 +1,32 @@
+import type { ModelAlias } from '@pythoughts/agent-core';
+import {
+ capabilitiesForModel,
+ type ManagedKimiCodeModelInfo,
+} from '@pythoughts/pythinker-code-oauth';
+
+/**
+ * Builds a pythinker-code model alias from an open-platform model entry, the
+ * managed-provider counterpart of `catalogModelToAlias`.
+ *
+ * Renderers need this to ask `effortLevelsForModel` what a managed model
+ * supports without depending on the oauth package themselves.
+ */
+export function managedModelToAlias(
+ platformId: string,
+ model: ManagedKimiCodeModelInfo,
+): ModelAlias {
+ return {
+ provider: platformId,
+ model: model.id,
+ maxContextSize: model.contextLength,
+ capabilities: capabilitiesForModel(model),
+ // Carry the declared efforts through, so the login picker offers what the
+ // model actually supports rather than the low/medium/high fallback. The
+ // config written after the picker records this same list.
+ supportEfforts:
+ model.supportedReasoningEfforts === undefined
+ ? undefined
+ : [...model.supportedReasoningEfforts],
+ displayName: model.displayName,
+ };
+}
diff --git a/packages/node-sdk/src/login/platform-options.ts b/packages/node-sdk/src/login/platform-options.ts
new file mode 100644
index 00000000..ddba30b5
--- /dev/null
+++ b/packages/node-sdk/src/login/platform-options.ts
@@ -0,0 +1,118 @@
+/**
+ * The list of platforms a user can log in to, built once and rendered by every
+ * surface: the TUI platform selector and the `pythinker login` terminal picker.
+ *
+ * Pure data — no renderer imports — so both surfaces offer the same providers in
+ * the same order. `PlatformOption` is structurally compatible with the TUI's
+ * `ChoiceOption`, which is why the selector can pass these straight through.
+ */
+
+import { OPENAI_CODEX_OAUTH_LOGIN, OPEN_PLATFORMS } from '@pythoughts/pythinker-code-oauth';
+import {
+ catalogConnectionWire,
+ type Catalog,
+ type CatalogProviderEntry,
+} from '#/catalog';
+
+import { CATALOG_PLATFORM_VALUE_PREFIX } from './platform-values';
+
+export interface PlatformOption {
+ /** Platform id, or a `catalog:`-prefixed catalog provider id. */
+ readonly value: string;
+ /** Display name shown in the picker. */
+ readonly label: string;
+ /** Secondary line: the auth kind (`OAuth` / `API key`) or the platform base URL. */
+ readonly description?: string;
+}
+
+/** Kimi's managed OAuth platform id — one option among many, never a default. */
+export const KIMI_CODE_PLATFORM_ID = 'kimi-code';
+
+const FEATURED_CATALOG_PROVIDERS = [
+ { id: 'deepseek', label: 'DeepSeek API' },
+ { id: 'zai-coding-plan', label: 'GLM Coding Plan' },
+ { id: 'minimax-coding-plan', label: 'MiniMax Token Plan' },
+ { id: 'kimi-for-coding', label: 'Kimi For Coding' },
+] as const;
+
+const REPLACED_OPEN_PLATFORM_IDS = new Set(['moonshot-ai', 'minimax-token']);
+
+function catalogOption(
+ providerId: string,
+ entry: CatalogProviderEntry | undefined,
+ label = entry?.name ?? providerId,
+): PlatformOption {
+ return {
+ value: `${CATALOG_PLATFORM_VALUE_PREFIX}${providerId}`,
+ label,
+ description:
+ typeof entry?.api === 'string' && entry.api.length > 0 ? entry.api : 'API key',
+ };
+}
+
+export function buildPlatformOptions(catalog: Catalog): readonly PlatformOption[] {
+ const options: PlatformOption[] = [
+ {
+ value: OPENAI_CODEX_OAUTH_LOGIN.id,
+ label: OPENAI_CODEX_OAUTH_LOGIN.name,
+ description: 'OAuth',
+ },
+ { value: KIMI_CODE_PLATFORM_ID, label: 'Kimi (OAuth)', description: 'OAuth' },
+ ];
+ const seen = new Set([KIMI_CODE_PLATFORM_ID, OPENAI_CODEX_OAUTH_LOGIN.id]);
+
+ for (const featured of FEATURED_CATALOG_PROVIDERS) {
+ const entry = catalog[featured.id];
+ if (entry === undefined || catalogConnectionWire(entry) === undefined) continue;
+ options.push(catalogOption(featured.id, entry, featured.label));
+ seen.add(featured.id);
+ }
+
+ const catalogEntries = Object.entries(catalog)
+ .filter(([id, entry]) => !seen.has(id) && catalogConnectionWire(entry) !== undefined)
+ .toSorted(([aId, a], [bId, b]) => (a.name ?? aId).localeCompare(b.name ?? bId));
+ for (const [id, entry] of catalogEntries) {
+ options.push(catalogOption(id, entry));
+ seen.add(id);
+ }
+
+ for (const platform of OPEN_PLATFORMS) {
+ if (
+ platform.catalogProviderId !== undefined ||
+ REPLACED_OPEN_PLATFORM_IDS.has(platform.id) ||
+ seen.has(platform.id)
+ ) {
+ continue;
+ }
+ options.push({
+ value: platform.id,
+ label: platform.name,
+ description: platform.baseUrl,
+ });
+ }
+ return options;
+}
+
+/**
+ * Resolve a user-supplied `--provider` value to a platform id, matching by id
+ * first and then by case-insensitive display name — the same rule the reference
+ * CLI uses. Returns `undefined` when nothing matches, so the caller can fail
+ * with the list of valid choices rather than silently picking one.
+ */
+export function resolvePlatformOption(
+ options: readonly PlatformOption[],
+ input: string,
+): PlatformOption | undefined {
+ const wanted = input.trim();
+ const byId = options.find((option) => option.value === wanted);
+ if (byId !== undefined) return byId;
+ const lowered = wanted.toLowerCase();
+ const byLabel = options.find((option) => option.label.toLowerCase() === lowered);
+ if (byLabel !== undefined) return byLabel;
+ // A catalog provider's value carries the internal `catalog:` prefix and its
+ // label is a product name ("DeepSeek API"), so the id the user actually knows
+ // — `deepseek` — matches neither rule above. Ids are unique across the whole
+ // option list, so accepting the bare form cannot become ambiguous.
+ const catalogValue = `${CATALOG_PLATFORM_VALUE_PREFIX}${lowered}`;
+ return options.find((option) => option.value.toLowerCase() === catalogValue);
+}
diff --git a/packages/node-sdk/src/login/platform-values.ts b/packages/node-sdk/src/login/platform-values.ts
new file mode 100644
index 00000000..15fbe19f
--- /dev/null
+++ b/packages/node-sdk/src/login/platform-values.ts
@@ -0,0 +1,19 @@
+/**
+ * Platform-selection value vocabulary, shared by the login flows and every
+ * renderer that presents them.
+ *
+ * These live in the SDK on purpose: the login flows are renderer
+ * agnostic, and importing them from the TUI's platform selector would pull the
+ * whole dialog stack (choice picker, theme, searchable list) into non-TUI
+ * callers such as the `pythinker login` subcommand.
+ */
+
+/** Marks a platform id as referring to a catalog provider rather than a built-in platform. */
+export const CATALOG_PLATFORM_VALUE_PREFIX = 'catalog:';
+
+/** The catalog provider id behind a platform value, or `undefined` for a non-catalog platform. */
+export function catalogProviderIdFromPlatformValue(value: string): string | undefined {
+ if (!value.startsWith(CATALOG_PLATFORM_VALUE_PREFIX)) return undefined;
+ const providerId = value.slice(CATALOG_PLATFORM_VALUE_PREFIX.length);
+ return providerId.length > 0 ? providerId : undefined;
+}
diff --git a/packages/node-sdk/src/login/types.ts b/packages/node-sdk/src/login/types.ts
new file mode 100644
index 00000000..ac5f8edb
--- /dev/null
+++ b/packages/node-sdk/src/login/types.ts
@@ -0,0 +1,54 @@
+import type {
+ DeviceAuthorization,
+ ManagedKimiCodeModelInfo,
+ OpenPlatformDefinition,
+} from '@pythoughts/pythinker-code-oauth';
+import type { Catalog, CatalogModel } from '#/catalog';
+import type { PythinkerHarness } from '#/pythinker-harness';
+
+export interface LoginProgressSpinnerHandle {
+ stop(opts: { ok: boolean; label: string }): void;
+}
+
+export interface ApiKeyPromptOptions {
+ readonly title?: string;
+ readonly subtitleLines?: readonly string[];
+ readonly secret?: boolean;
+ readonly emptyMessage?: string;
+}
+
+export interface PlatformSelection {
+ readonly platformId: string;
+ readonly catalog: Catalog;
+}
+
+export interface LoginUi {
+ readonly harness: PythinkerHarness;
+ readonly sessionId?: string;
+ cancelInFlight: (() => void) | undefined;
+ /**
+ * Open a URL in the user's browser. Renderer-owned on purpose: a terminal
+ * shells out, while an editor extension uses its own host API.
+ */
+ openBrowser(url: string): void;
+ showStatus(message: string): void;
+ showError(message: string): void;
+ showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
+ showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
+ promptPlatformSelection(): Promise;
+ promptApiKey(
+ platformName: string,
+ subtitleLines?: readonly string[],
+ options?: ApiKeyPromptOptions,
+ ): Promise;
+ promptModelSelectionForOpenPlatform(
+ models: ManagedKimiCodeModelInfo[],
+ platform: OpenPlatformDefinition,
+ ): Promise<{ model: ManagedKimiCodeModelInfo; effort: string } | undefined>;
+ promptModelSelectionForCatalog(
+ providerId: string,
+ models: CatalogModel[],
+ ): Promise<{ model: CatalogModel; effort: string } | undefined>;
+ refreshConfigAfterLogin(): Promise;
+ track(event: string, props?: Record): void;
+}
diff --git a/packages/node-sdk/src/thinking-levels.ts b/packages/node-sdk/src/thinking-levels.ts
new file mode 100644
index 00000000..b6f613d6
--- /dev/null
+++ b/packages/node-sdk/src/thinking-levels.ts
@@ -0,0 +1,81 @@
+/**
+ * Thinking-effort level rules, shared by every surface.
+ *
+ * Models declare their selectable effort levels via `ModelAlias.supportEfforts`;
+ * when that metadata is missing the fallback set is low / medium / high.
+ * Clamping semantics mirror the Web UI's `coerceThinkingForModel`
+ * (apps/pythinker-web/src/lib/modelThinking.ts).
+ *
+ * This lives in the SDK rather than in a single app because the login flows
+ * are rendered by more than one front end: the terminal renderer and the VS
+ * Code renderer must offer the same levels for the same model.
+ */
+
+import type { ModelAlias } from '@pythoughts/agent-core';
+
+export type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported';
+
+export const CANONICAL_EFFORT_ORDER = [
+ 'off',
+ 'minimal',
+ 'low',
+ 'medium',
+ 'high',
+ 'xhigh',
+ 'max',
+] as const;
+
+/** Fallback effort set for models without `supportEfforts` metadata. */
+export const DEFAULT_SUPPORTED_EFFORTS: readonly string[] = ['low', 'medium', 'high'];
+
+export function thinkingAvailability(model: ModelAlias | undefined): ThinkingAvailability {
+ if (model === undefined) return 'toggle';
+ const caps = model.capabilities ?? [];
+ if (caps.includes('always_thinking')) return 'always-on';
+ if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle';
+ return 'unsupported';
+}
+
+/** Effort levels the model actually supports ('off' excluded), canonical order. */
+function baseEffortsForModel(model: ModelAlias | undefined): string[] {
+ const supportEfforts = model?.supportEfforts;
+ if (supportEfforts?.length === 0) return ['high'];
+ const declared = new Set(
+ (supportEfforts ?? []).filter((effort) =>
+ (CANONICAL_EFFORT_ORDER as readonly string[]).includes(effort),
+ ),
+ );
+ const ordered = CANONICAL_EFFORT_ORDER.filter(
+ (effort) => effort !== 'off' && declared.has(effort),
+ );
+ return ordered.length > 0 ? [...ordered] : [...DEFAULT_SUPPORTED_EFFORTS];
+}
+
+/** Selectable levels for a model, in order. `['off']` when thinking is unsupported. */
+export function effortLevelsForModel(model: ModelAlias | undefined): string[] {
+ const availability = thinkingAvailability(model);
+ if (availability === 'unsupported') return ['off'];
+ const base = baseEffortsForModel(model);
+ return availability === 'always-on' ? base : ['off', ...base];
+}
+
+/**
+ * Clamp a requested effort to what the model supports:
+ * always-on + 'off' → first supported level; unsupported → 'off'; a level the
+ * model lacks → nearest lower supported level (else the first selectable one).
+ * The legacy boolean-era value 'on' maps to 'high' before clamping.
+ */
+export function coerceEffortForModel(model: ModelAlias | undefined, requested: string): string {
+ const availability = thinkingAvailability(model);
+ if (availability === 'unsupported') return 'off';
+ const levels = effortLevelsForModel(model);
+ const normalized = requested === 'on' ? 'high' : requested;
+ if (availability === 'always-on' && normalized === 'off') return levels[0]!;
+ if (levels.includes(normalized)) return normalized;
+ const requestedIdx = (CANONICAL_EFFORT_ORDER as readonly string[]).indexOf(normalized);
+ for (let i = requestedIdx - 1; i >= 0; i--) {
+ const candidate = CANONICAL_EFFORT_ORDER[i]!;
+ if (levels.includes(candidate)) return candidate;
+ }
+ return levels[0]!;
+}
diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts
index 411890af..0541102a 100644
--- a/packages/node-sdk/test/catalog.test.ts
+++ b/packages/node-sdk/test/catalog.test.ts
@@ -11,6 +11,8 @@ import {
importCatalogProvider,
type CatalogModel,
} from '../src/catalog';
+import { managedModelToAlias } from '../src/login/model-alias';
+import { effortLevelsForModel } from '../src/thinking-levels';
function catalogResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -54,6 +56,18 @@ describe('fetchCatalog', () => {
fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch),
).rejects.toThrow(/Unexpected catalog response/);
});
+
+ it('drops entries that are not objects instead of failing the whole catalog', async () => {
+ // A malformed entry reaches `catalogConnectionWire` and throws well past
+ // the caller's bundled-catalog fallback, so one bad record used to take the
+ // whole provider picker down with it.
+ const good = { id: 'anthropic', models: { x: { id: 'x', limit: { context: 1000 } } } };
+ const fetchMock = vi.fn(async () =>
+ catalogResponse({ anthropic: good, broken: null, alsoBroken: 'nope', listy: [1] }),
+ );
+ const result = await fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch);
+ expect(result).toEqual({ anthropic: good });
+ });
});
describe('catalogModelToAlias', () => {
@@ -128,6 +142,43 @@ describe('applyCatalogProvider', () => {
expect(config.defaultThinking).toBe(true);
});
+ it('persists the picked effort level, not just the on/off bit', () => {
+ const config = { providers: {} } as PythinkerConfig;
+ applyCatalogProvider(config, {
+ providerId: 'anthropic',
+ wire: 'anthropic',
+ apiKey: 'test-key',
+ models: [model],
+ selectedModelId: 'm1',
+ thinking: true,
+ effort: 'medium',
+ });
+
+ // defaultThinking is a boolean, so without config.thinking.effort the
+ // session reopens at 'high' no matter which level the user chose.
+ expect(config.defaultThinking).toBe(true);
+ expect(config.thinking?.effort).toBe('medium');
+ });
+
+ it('overwrites a previous effort when the user turns thinking off', () => {
+ const config = { providers: {}, thinking: { effort: 'high' } } as PythinkerConfig;
+ applyCatalogProvider(config, {
+ providerId: 'anthropic',
+ wire: 'anthropic',
+ apiKey: 'test-key',
+ models: [model],
+ selectedModelId: 'm1',
+ thinking: false,
+ effort: 'off',
+ });
+
+ // 'off' is written rather than deleted: callers persist through `setConfig`,
+ // a deep merge that cannot remove a key, so leaving it out would keep 'high'
+ // on disk for the next session.
+ expect(config.defaultThinking).toBe(false);
+ expect(config.thinking?.effort).toBe('off');
+ });
+
it('writes interleaved reasoning key from a catalog-selected model alias', () => {
const models = catalogProviderModels({
id: 'deepseek',
@@ -313,3 +364,44 @@ describe('importCatalogProvider', () => {
).rejects.toThrow(/is not offered/);
});
});
+
+describe('managedModelToAlias', () => {
+ const managed = {
+ id: 'gpt-5-codex',
+ contextLength: 256_000,
+ supportsReasoning: true,
+ supportedReasoningEfforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
+ supportsImageIn: true,
+ supportsVideoIn: false,
+ supportsThinkingType: 'both',
+ displayName: 'GPT-5 Codex',
+ } as const;
+
+ it('carries the declared reasoning efforts into the alias', () => {
+ // Dropping them silently offered the low/medium/high fallback at the login
+ // picker while the config written right after recorded the real list.
+ expect(managedModelToAlias('openai-codex', managed)).toMatchObject({
+ provider: 'openai-codex',
+ model: 'gpt-5-codex',
+ maxContextSize: 256_000,
+ supportEfforts: ['minimal', 'low', 'medium', 'high', 'xhigh'],
+ });
+ expect(effortLevelsForModel(managedModelToAlias('openai-codex', managed))).toEqual([
+ 'off',
+ 'minimal',
+ 'low',
+ 'medium',
+ 'high',
+ 'xhigh',
+ ]);
+ });
+
+ it('leaves the efforts undefined when the model declares none', () => {
+ const alias = managedModelToAlias('openai-codex', {
+ ...managed,
+ supportedReasoningEfforts: undefined,
+ });
+ expect(alias.supportEfforts).toBeUndefined();
+ expect(effortLevelsForModel(alias)).toEqual(['off', 'low', 'medium', 'high']);
+ });
+});
diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts
index 032a1471..eaa6be69 100644
--- a/packages/node-sdk/test/session-event-types.test.ts
+++ b/packages/node-sdk/test/session-event-types.test.ts
@@ -101,6 +101,7 @@ describe('Event public types', () => {
case 'subagent.suspended':
case 'subagent.completed':
case 'subagent.failed':
+ case 'workflow.warning':
case 'compaction.started':
case 'compaction.blocked':
case 'compaction.cancelled':
diff --git a/packages/node-sdk/test/thinking-levels.test.ts b/packages/node-sdk/test/thinking-levels.test.ts
new file mode 100644
index 00000000..51642b3e
--- /dev/null
+++ b/packages/node-sdk/test/thinking-levels.test.ts
@@ -0,0 +1,105 @@
+import type { ModelAlias } from '@pythoughts/agent-core';
+import { describe, expect, it } from 'vitest';
+
+import { coerceEffortForModel, effortLevelsForModel } from '../src/thinking-levels';
+
+function model(overrides: Partial = {}): ModelAlias {
+ return {
+ provider: 'managed:kimi-code',
+ model: 'k2',
+ maxContextSize: 200_000,
+ capabilities: ['thinking'],
+ ...overrides,
+ } as ModelAlias;
+}
+
+describe('effortLevelsForModel', () => {
+ it('falls back to low/medium/high without supportEfforts metadata', () => {
+ expect(effortLevelsForModel(model())).toEqual(['off', 'low', 'medium', 'high']);
+ expect(effortLevelsForModel(undefined)).toEqual(['off', 'low', 'medium', 'high']);
+ });
+
+ it('uses declared supportEfforts in canonical order, filtering unknown values', () => {
+ expect(
+ effortLevelsForModel(
+ model({ supportEfforts: ['max', 'none', 'low', 'bogus', 'minimal', 'high'] }),
+ ),
+ ).toEqual(['off', 'minimal', 'low', 'high', 'max']);
+ });
+
+ it('uses one fixed level for explicit empty effort metadata', () => {
+ expect(
+ effortLevelsForModel(
+ model({ capabilities: ['always_thinking'], supportEfforts: [] }),
+ ),
+ ).toEqual(['high']);
+ });
+
+ it('falls back when supportEfforts only contains unknown values', () => {
+ expect(effortLevelsForModel(model({ supportEfforts: ['bogus'] }))).toEqual([
+ 'off',
+ 'low',
+ 'medium',
+ 'high',
+ ]);
+ });
+
+ it('returns only off for unsupported models', () => {
+ expect(effortLevelsForModel(model({ capabilities: ['tool_use'] }))).toEqual(['off']);
+ });
+
+ it('excludes off for always-on models', () => {
+ expect(
+ effortLevelsForModel(model({ capabilities: ['always_thinking'], supportEfforts: ['high'] })),
+ ).toEqual(['high']);
+ });
+
+ it('treats adaptiveThinking models as toggleable', () => {
+ expect(effortLevelsForModel(model({ capabilities: [], adaptiveThinking: true }))).toEqual([
+ 'off',
+ 'low',
+ 'medium',
+ 'high',
+ ]);
+ });
+});
+
+describe('coerceEffortForModel', () => {
+ it('passes through supported levels', () => {
+ expect(coerceEffortForModel(model(), 'medium')).toBe('medium');
+ expect(coerceEffortForModel(model(), 'off')).toBe('off');
+ });
+
+ it('clamps an unsupported level down to the nearest lower one', () => {
+ expect(coerceEffortForModel(model({ supportEfforts: ['low', 'high'] }), 'max')).toBe('high');
+ expect(coerceEffortForModel(model({ supportEfforts: ['low', 'high'] }), 'medium')).toBe('low');
+ });
+
+ it('falls back to the first level when nothing lower is supported', () => {
+ expect(coerceEffortForModel(model({ supportEfforts: ['max'] }), 'low')).toBe('off');
+ expect(
+ coerceEffortForModel(
+ model({ capabilities: ['always_thinking'], supportEfforts: ['max'] }),
+ 'low',
+ ),
+ ).toBe('max');
+ });
+
+ it('maps always-on + off to the first supported level', () => {
+ expect(
+ coerceEffortForModel(
+ model({ capabilities: ['always_thinking'], supportEfforts: ['high', 'max'] }),
+ 'off',
+ ),
+ ).toBe('high');
+ });
+
+ it('maps unsupported models to off', () => {
+ expect(coerceEffortForModel(model({ capabilities: ['tool_use'] }), 'high')).toBe('off');
+ });
+
+ it('maps the legacy on value to high before clamping', () => {
+ expect(coerceEffortForModel(model(), 'on')).toBe('high');
+ expect(coerceEffortForModel(model({ supportEfforts: ['low'] }), 'on')).toBe('low');
+ });
+});
diff --git a/packages/oauth/src/oauth.ts b/packages/oauth/src/oauth.ts
index 15f16db0..963534da 100644
--- a/packages/oauth/src/oauth.ts
+++ b/packages/oauth/src/oauth.ts
@@ -108,6 +108,23 @@ function describeFetchFailure(error: unknown): string {
return [...messages].join(': ');
}
+/**
+ * Whether a URL is safe to hand to a browser.
+ *
+ * The verification URLs come off the wire and every renderer opens them with
+ * the host's "open this externally" API, so a provider that answered with
+ * `file:`, `javascript:`, or an installed app's custom scheme would have the
+ * agent launch it. Checked here, at the boundary the response is parsed, rather
+ * than in each renderer.
+ */
+function isHttpsUrl(value: string): boolean {
+ try {
+ return new URL(value).protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
// ── requestDeviceAuthorization ────────────────────────────────────────
export async function requestDeviceAuthorization(
@@ -140,11 +157,19 @@ export async function requestDeviceAuthorization(
if (typeof verificationUriComplete !== 'string' || verificationUriComplete.length === 0) {
throw new OAuthError('Device authorization response missing verification_uri_complete');
}
+ if (!isHttpsUrl(verificationUriComplete)) {
+ throw new OAuthError('Device authorization response has a non-HTTPS verification_uri_complete');
+ }
+ const verificationUri = data['verification_uri'];
+ if (typeof verificationUri === 'string' && verificationUri.length > 0
+ && !isHttpsUrl(verificationUri)) {
+ throw new OAuthError('Device authorization response has a non-HTTPS verification_uri');
+ }
return {
userCode,
deviceCode,
- verificationUri: typeof data['verification_uri'] === 'string' ? data['verification_uri'] : '',
+ verificationUri: typeof verificationUri === 'string' ? verificationUri : '',
verificationUriComplete,
expiresIn: data['expires_in'] !== undefined ? Number(data['expires_in']) : null,
interval: Number(data['interval'] ?? 5),
diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts
index 5ada7d12..3edb08be 100644
--- a/packages/oauth/src/open-platform.ts
+++ b/packages/oauth/src/open-platform.ts
@@ -244,6 +244,8 @@ export function applyOpenPlatformConfig(
readonly models: readonly ManagedKimiCodeModelInfo[];
readonly selectedModel: ManagedKimiCodeModelInfo;
readonly thinking: boolean;
+ /** The effort level the user picked; only the on/off bit persists without it. */
+ readonly effort?: string;
readonly apiKey: string;
},
): ApplyOpenPlatformResult {
@@ -277,6 +279,13 @@ export function applyOpenPlatformConfig(
config.models = existingModels;
config.defaultModel = modelKey;
config.defaultThinking = options.thinking;
+ // defaultThinking is a boolean, so without this the picked level is lost and
+ // the session reopens at 'high' regardless of what the user chose. 'off' is
+ // written too rather than skipped: `setConfig` deep-merges and cannot delete a
+ // key, so skipping it would leave a previous login's effort on disk.
+ if (options.effort !== undefined) {
+ config.thinking = { ...config.thinking, effort: options.effort };
+ }
return { defaultModel: modelKey, defaultThinking: options.thinking };
}
diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts
index 5067fd74..3c435e0b 100644
--- a/packages/oauth/src/openai-codex-oauth.ts
+++ b/packages/oauth/src/openai-codex-oauth.ts
@@ -293,6 +293,14 @@ export async function startOpenAICodexCallbackServer(
return new Promise<{ code: string } | null>((resolveWait, rejectWait) => {
let timer: NodeJS.Timeout | undefined;
const onAbort = (): void => {
+ // Tear down here rather than leaning on the `waitPromise.then`
+ // below: an already-aborted signal returns before that handler
+ // is ever registered, which left both the full timeout and the
+ // listening callback server alive to hold the host's event
+ // loop. Every other exit from the wait closes the server, so
+ // this path has to as well.
+ cleanup();
+ close();
settleWait?.(null);
settleWait = undefined;
rejectWait(
@@ -573,6 +581,11 @@ export function applyOpenAICodexOAuthConfig(
readonly models: readonly ManagedKimiCodeModelInfo[];
readonly selectedModel: ManagedKimiCodeModelInfo;
readonly thinking?: boolean | undefined;
+ /**
+ * The effort level the user picked. Omitted, the model's top supported
+ * effort is used, which is what callers that never asked the user get.
+ */
+ readonly effort?: string;
},
): ApplyOpenAICodexOAuthResult {
if (options.models.length === 0) {
@@ -630,7 +643,15 @@ export function applyOpenAICodexOAuthConfig(
? 'max'
: CODEX_REASONING_EFFORT_ORDER.findLast((effort) => supportedEfforts.includes(effort)) ??
'max';
- config.thinking = { ...config.thinking, effort: topEffort };
+ // A level the user picked wins over the derived top effort, but only when the
+ // model actually declares it — otherwise we would persist an unusable level.
+ const picked =
+ options.effort !== undefined &&
+ options.effort !== 'off' &&
+ (supportedEfforts === undefined || supportedEfforts.includes(options.effort))
+ ? options.effort
+ : undefined;
+ config.thinking = { ...config.thinking, effort: picked ?? topEffort };
return { defaultModel: modelKey, defaultThinking: config.defaultThinking ?? true };
}
diff --git a/packages/oauth/test/oauth.test.ts b/packages/oauth/test/oauth.test.ts
index 36a4855c..2626b2e0 100644
--- a/packages/oauth/test/oauth.test.ts
+++ b/packages/oauth/test/oauth.test.ts
@@ -273,6 +273,44 @@ describe('requestDeviceAuthorization', () => {
await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError);
});
+ // Every renderer hands these URLs straight to the host's "open externally"
+ // API, so a provider answering with `file:` or an app's custom scheme would
+ // have the agent launch it. Rejected here, where the response is parsed,
+ // rather than in each renderer.
+ it.each([
+ ['file:///etc/passwd'],
+ ['javascript:alert(1)'],
+ ['vscode://extension/install?id=evil'],
+ ['not a url'],
+ ])('rejects a non-HTTPS verification_uri_complete (%s)', async (uri) => {
+ server.enqueue('/api/oauth/device_authorization', {
+ status: 200,
+ body: {
+ user_code: 'U',
+ device_code: 'D',
+ verification_uri_complete: uri,
+ expires_in: 60,
+ interval: 5,
+ },
+ });
+ await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError);
+ });
+
+ it('rejects a non-HTTPS verification_uri even when the complete one is safe', async () => {
+ server.enqueue('/api/oauth/device_authorization', {
+ status: 200,
+ body: {
+ user_code: 'U',
+ device_code: 'D',
+ verification_uri: 'file:///etc/passwd',
+ verification_uri_complete: 'https://auth.kimi.com/verify?user_code=U',
+ expires_in: 60,
+ interval: 5,
+ },
+ });
+ await expect(requestAuth()).rejects.toBeInstanceOf(OAuthError);
+ });
+
it('surfaces message fields from failed device authorization responses', async () => {
server.enqueue('/api/oauth/device_authorization', {
status: 400,
diff --git a/packages/oauth/test/open-platform.test.ts b/packages/oauth/test/open-platform.test.ts
index 395b97e6..10f93a12 100644
--- a/packages/oauth/test/open-platform.test.ts
+++ b/packages/oauth/test/open-platform.test.ts
@@ -314,6 +314,41 @@ describe('applyOpenPlatformConfig', () => {
expect(config.services).toBeUndefined();
});
+ it('persists the picked effort, and overwrites a previous one when thinking is off', () => {
+ const platform = getOpenPlatformById('moonshot-cn')!;
+ const models = [
+ { id: 'kimi-k2-0712-preview', contextLength: 256000, supportsReasoning: true, supportsImageIn: false, supportsVideoIn: false },
+ ];
+
+ const picked: ManagedKimiConfigShape = { providers: {} };
+ applyOpenPlatformConfig(picked, {
+ platform,
+ models,
+ selectedModel: models[0]!,
+ thinking: true,
+ effort: 'medium',
+ apiKey: 'sk-test',
+ });
+ // defaultThinking is a boolean, so without this the level is lost and the
+ // session reopens at the default no matter what the user chose.
+ expect(picked.thinking?.effort).toBe('medium');
+
+ const turnedOff: ManagedKimiConfigShape = { providers: {}, thinking: { effort: 'high' } };
+ applyOpenPlatformConfig(turnedOff, {
+ platform,
+ models,
+ selectedModel: models[0]!,
+ thinking: false,
+ effort: 'off',
+ apiKey: 'sk-test',
+ });
+ // 'off' is written rather than skipped: the caller persists through a
+ // `setConfig` deep merge, which cannot delete a key, so omitting it would
+ // leave 'high' on disk for the next session.
+ expect(turnedOff.defaultThinking).toBe(false);
+ expect(turnedOff.thinking?.effort).toBe('off');
+ });
+
it('clears stale models for the same provider', () => {
const config: ManagedKimiConfigShape = {
providers: {
diff --git a/packages/oauth/test/openai-codex-oauth.test.ts b/packages/oauth/test/openai-codex-oauth.test.ts
index b736ec58..42893f35 100644
--- a/packages/oauth/test/openai-codex-oauth.test.ts
+++ b/packages/oauth/test/openai-codex-oauth.test.ts
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto';
+import { createServer as createNetServer } from 'node:net';
import { describe, expect, it, vi } from 'vitest';
import type { ManagedKimiConfigShape } from '../src/managed-kimi-code';
@@ -9,6 +10,7 @@ import {
extractOpenAICodexAccountId,
fetchOpenAICodexModels,
parseOpenAICodexAuthorizationInput,
+ startOpenAICodexCallbackServer,
} from '../src/openai-codex-oauth';
import { renderOpenAICodexOAuthSuccessPage } from '../src/oauth-pages';
@@ -273,3 +275,55 @@ describe('openai-codex-oauth', () => {
expect(config.thinking).toEqual({ mode: 'auto', effort: 'xhigh' });
});
});
+
+describe('startOpenAICodexCallbackServer', () => {
+ /** Resolves true once nothing is listening on the callback port. */
+ async function portFreedWithin(port: number, deadlineMs: number): Promise {
+ const started = Date.now();
+ for (;;) {
+ const free = await new Promise((resolve) => {
+ const probe = createNetServer();
+ probe.once('error', () => {
+ resolve(false);
+ });
+ probe.listen(port, '127.0.0.1', () => {
+ probe.close(() => {
+ resolve(true);
+ });
+ });
+ });
+ if (free) return true;
+ if (Date.now() - started > deadlineMs) return false;
+ await new Promise((resolve) => {
+ setTimeout(resolve, 20);
+ });
+ }
+ }
+
+ it('tears the wait down when it is aborted before it starts', async () => {
+ const server = await startOpenAICodexCallbackServer('state-abc');
+ // Fake timers only count what is scheduled while they are installed, so
+ // install them after the (real, I/O-bound) bind and before the wait.
+ vi.useFakeTimers();
+ try {
+ // An already-aborted signal takes the early-return branch, which never
+ // registers the completion handler every other exit relies on. Left to
+ // it, both the two-minute timeout and the listening server outlive the
+ // cancelled login and pin the host event loop.
+ await expect(
+ server.waitForCode({
+ signal: AbortSignal.abort(new Error('login cancelled')),
+ timeoutMs: 120_000,
+ }),
+ ).rejects.toThrow('login cancelled');
+ expect(vi.getTimerCount()).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ // Probed before this test closes anything: the abort path has to release
+ // the port on its own, the way every other exit from the wait does.
+ const freed = await portFreedWithin(1455, 1_000);
+ server.close();
+ expect(freed).toBe(true);
+ });
+});
diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts
index e812b89c..d8c086fc 100644
--- a/packages/protocol/src/__tests__/events.test.ts
+++ b/packages/protocol/src/__tests__/events.test.ts
@@ -16,6 +16,7 @@ import {
subagentStartedEventSchema,
subagentSuspendedEventSchema,
toolCallStartedEventSchema,
+ workflowWarningEventSchema,
} from '../events';
import type { Event } from '../events';
import type { ToolInputDisplay } from '../display';
@@ -158,6 +159,39 @@ describe('events / display re-exports', () => {
}
});
+ it('validates workflow.warning events and requires a run id', () => {
+ const warning = {
+ type: 'workflow.warning' as const,
+ workflowRunId: 'wfr-test-001',
+ parentToolCallId: 'call_1',
+ agentCount: 26,
+ threshold: 25,
+ message: 'This Dynamic Workflow will launch 26 subagents, above the advisory ceiling of 25; the run is proceeding anyway.',
+ };
+
+ expect(workflowWarningEventSchema.parse(warning)).toEqual(warning);
+ // Also through the union: registering the schema but forgetting the
+ // discriminatedUnion member would leave the event unserializable.
+ expect(agentEventSchema.parse(warning)).toEqual(warning);
+ expect(
+ workflowWarningEventSchema.safeParse({
+ type: 'workflow.warning',
+ agentCount: 26,
+ threshold: 25,
+ message: 'missing run id',
+ }).success,
+ ).toBe(false);
+ expect(
+ workflowWarningEventSchema.safeParse({
+ type: 'workflow.warning',
+ workflowRunId: 'wfr-test-001',
+ agentCount: 26,
+ threshold: 25,
+ message: 'missing parent tool call id',
+ }).success,
+ ).toBe(false);
+ });
+
it('validates model rates and accumulated spend while rejecting unknown status keys', () => {
const status = {
type: 'agent.status.updated' as const,
diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts
index 812013fd..4b88fc5d 100644
--- a/packages/protocol/src/events.ts
+++ b/packages/protocol/src/events.ts
@@ -517,6 +517,10 @@ export interface SubagentSpawnedEvent {
readonly parentAgentId?: string;
readonly description?: string;
readonly dynamicWorkflowIndex?: number;
+ /** Identifies the Dynamic Workflow run this subagent belongs to; absent outside a workflow. */
+ readonly workflowRunId?: string;
+ /** The workflow's user-facing description, repeated on each subagent for correlation. */
+ readonly workflowName?: string;
readonly runInBackground: boolean;
}
@@ -525,6 +529,8 @@ export interface SubagentStartedEvent {
readonly subagentId: string;
/** Tool call in the parent agent that spawned the subagent; absent when spawned outside a tool call. */
readonly parentToolCallId?: string;
+ /** Identifies the Dynamic Workflow run this subagent belongs to; absent outside a workflow. */
+ readonly workflowRunId?: string;
}
export interface SubagentSuspendedEvent {
@@ -540,6 +546,8 @@ export interface SubagentCompletedEvent {
readonly subagentId: string;
/** Tool call in the parent agent that spawned the subagent; absent when spawned outside a tool call. */
readonly parentToolCallId?: string;
+ /** Identifies the Dynamic Workflow run this subagent belongs to; absent outside a workflow. */
+ readonly workflowRunId?: string;
readonly resultSummary: string;
readonly usage?: TokenUsage;
readonly contextTokens?: number;
@@ -550,9 +558,23 @@ export interface SubagentFailedEvent {
readonly subagentId: string;
/** Tool call in the parent agent that spawned the subagent; absent when spawned outside a tool call. */
readonly parentToolCallId?: string;
+ /** Identifies the Dynamic Workflow run this subagent belongs to; absent outside a workflow. */
+ readonly workflowRunId?: string;
readonly error: string;
}
+export interface WorkflowWarningEvent {
+ readonly type: 'workflow.warning';
+ readonly workflowRunId: string;
+ /** Tool call in the parent agent that launched this workflow. */
+ readonly parentToolCallId: string;
+ /** Number of subagents this run will launch. */
+ readonly agentCount: number;
+ /** The advisory ceiling that was exceeded. */
+ readonly threshold: number;
+ readonly message: string;
+}
+
export interface CompactionStartedEvent {
readonly type: 'compaction.started';
readonly trigger: 'manual' | 'auto';
@@ -653,6 +675,7 @@ export type AgentEvent =
| SubagentSuspendedEvent
| SubagentCompletedEvent
| SubagentFailedEvent
+ | WorkflowWarningEvent
| CompactionStartedEvent
| CompactionBlockedEvent
| CompactionCancelledEvent
@@ -1175,6 +1198,8 @@ export const subagentSpawnedEventSchema = z.object({
parentAgentId: z.string().optional(),
description: z.string().optional(),
dynamicWorkflowIndex: z.number().optional(),
+ workflowRunId: z.string().optional(),
+ workflowName: z.string().optional(),
runInBackground: z.boolean(),
}).strict() satisfies z.ZodType;
@@ -1182,6 +1207,7 @@ export const subagentStartedEventSchema = z.object({
type: z.literal('subagent.started'),
subagentId: z.string(),
parentToolCallId: z.string().optional(),
+ workflowRunId: z.string().optional(),
}) satisfies z.ZodType;
export const subagentSuspendedEventSchema = z.object({
@@ -1195,6 +1221,7 @@ export const subagentCompletedEventSchema = z.object({
type: z.literal('subagent.completed'),
subagentId: z.string(),
parentToolCallId: z.string().optional(),
+ workflowRunId: z.string().optional(),
resultSummary: z.string(),
usage: tokenUsageSchema.optional(),
contextTokens: z.number().optional(),
@@ -1204,9 +1231,19 @@ export const subagentFailedEventSchema = z.object({
type: z.literal('subagent.failed'),
subagentId: z.string(),
parentToolCallId: z.string().optional(),
+ workflowRunId: z.string().optional(),
error: z.string(),
}) satisfies z.ZodType;
+export const workflowWarningEventSchema = z.object({
+ type: z.literal('workflow.warning'),
+ workflowRunId: z.string(),
+ parentToolCallId: z.string(),
+ agentCount: z.number(),
+ threshold: z.number(),
+ message: z.string(),
+}) satisfies z.ZodType;
+
export const compactionStartedEventSchema = z.object({
type: z.literal('compaction.started'),
trigger: z.enum(['manual', 'auto']),
@@ -1310,6 +1347,7 @@ export const agentEventSchema = z.discriminatedUnion('type', [
subagentSuspendedEventSchema,
subagentCompletedEventSchema,
subagentFailedEventSchema,
+ workflowWarningEventSchema,
compactionStartedEventSchema,
compactionBlockedEventSchema,
compactionCancelledEventSchema,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f9460ee..a5cfeb68 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,6 +152,9 @@ importers:
apps/pythinker-code:
dependencies:
+ '@clack/prompts':
+ specifier: 1.7.0
+ version: 1.7.0
'@opentui/core':
specifier: 0.4.3
version: 0.4.3(typescript@6.0.3)(web-tree-sitter@0.25.10)
@@ -480,7 +483,7 @@ importers:
version: 3.5.2(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0))
vitest:
specifier: 4.1.4
- version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0))
+ version: 4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0))
docs:
dependencies:
@@ -1262,6 +1265,14 @@ packages:
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+ '@clack/core@1.4.3':
+ resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==}
+ engines: {node: '>= 20.12.0'}
+
+ '@clack/prompts@1.7.0':
+ resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
+ engines: {node: '>= 20.12.0'}
+
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -10381,6 +10392,18 @@ snapshots:
'@chevrotain/types@11.1.2': {}
+ '@clack/core@1.4.3':
+ dependencies:
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
+ '@clack/prompts@1.7.0':
+ dependencies:
+ '@clack/core': 1.4.3
+ fast-string-width: 3.0.2
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
'@colors/colors@1.5.0':
optional: true
@@ -19073,7 +19096,7 @@ snapshots:
- typescript
- universal-cookie
- vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)):
+ vitest@4.1.4(@types/node@22.20.1)(@vitest/coverage-v8@4.1.9(vitest@4.1.9))(jsdom@25.0.1)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.4
'@vitest/mocker': 4.1.4(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.5)(yaml@2.9.0))
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 00000000..466b8e76
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "skills": {
+ "stop-slop": {
+ "source": "hardikpandya/stop-slop",
+ "sourceType": "github",
+ "skillPath": "SKILL.md",
+ "computedHash": "617bf97d33162a6bd0da4f40a97aac589de58e5a6c9cf011ed6108992210e7cb"
+ }
+ }
+}