@ziggs-ai/agent-sdk 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Hosted fleet agents connect via api-client (AgentHost), never inbound ziggs-mcp.
3
+ * @see ZIG-781
4
+ */
5
+
6
+ export type McpServerSpec = {
7
+ type?: string;
8
+ command?: string;
9
+ args?: string[];
10
+ };
11
+
12
+ export function looksLikeInboundZiggsMcp(command: string, args: string[] = []): boolean {
13
+ const joined = `${command} ${args.join(' ')}`;
14
+ return /ziggs-mcp|@ziggs-ai\/ziggs-mcp/i.test(joined);
15
+ }
16
+
17
+ /**
18
+ * Reject wiring a hosted agent to the inbound Ziggs MCP stdio surface.
19
+ * Claude SDK agents must use in-process ToolManager tools (createSdkMcpServer).
20
+ */
21
+ export function assertHostedAgentZiggsTools(
22
+ mcpServers: Record<string, McpServerSpec | unknown> | undefined,
23
+ ): void {
24
+ if (!mcpServers) return;
25
+ for (const [name, raw] of Object.entries(mcpServers)) {
26
+ if (!raw || typeof raw !== 'object') continue;
27
+ const spec = raw as McpServerSpec;
28
+ if (spec.type === 'stdio') {
29
+ throw new Error(
30
+ `Hosted Ziggs agents must not use stdio MCP (server "${name}"). ` +
31
+ 'Connect via api-client / AgentHost in-process tools.',
32
+ );
33
+ }
34
+ if (looksLikeInboundZiggsMcp(String(spec.command ?? ''), spec.args ?? [])) {
35
+ throw new Error(
36
+ `Hosted Ziggs agents must not spawn ziggs-mcp (inbound MCP server "${name}"). ` +
37
+ 'Use AgentHost + in-process tools.',
38
+ );
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,136 @@
1
+ import { z } from 'zod';
2
+ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
3
+ import type { SdkMcpToolDefinition } from '@anthropic-ai/claude-agent-sdk';
4
+ import type { ToolDefinition } from '../tools/defineTool.js';
5
+ import type { ClaudeSdkToolContext } from './claudeSdkTools.js';
6
+
7
+ type JsonSchema = Record<string, unknown>;
8
+
9
+ type CallToolResult = {
10
+ content: Array<{ type: 'text'; text: string }>;
11
+ isError?: boolean;
12
+ };
13
+
14
+ function jsonTypeToZod(field: JsonSchema): z.ZodTypeAny {
15
+ const t = field['type'];
16
+ if (t === 'string') {
17
+ let s: z.ZodTypeAny = z.string();
18
+ if (Array.isArray(field['enum'])) {
19
+ const vals = field['enum'] as string[];
20
+ if (vals.length > 0) s = z.enum(vals as [string, ...string[]]);
21
+ }
22
+ if (typeof field['description'] === 'string') s = s.describe(field['description']);
23
+ return s;
24
+ }
25
+ if (t === 'number' || t === 'integer') {
26
+ let n: z.ZodTypeAny = z.number();
27
+ if (typeof field['description'] === 'string') n = n.describe(field['description']);
28
+ return n;
29
+ }
30
+ if (t === 'boolean') return z.boolean();
31
+ if (t === 'array') {
32
+ const items = (field['items'] as JsonSchema | undefined) ?? { type: 'string' };
33
+ return z.array(jsonTypeToZod(items));
34
+ }
35
+ if (t === 'object') {
36
+ const props = (field['properties'] as Record<string, JsonSchema> | undefined) ?? {};
37
+ const required = new Set((field['required'] as string[] | undefined) ?? []);
38
+ const shape: Record<string, z.ZodTypeAny> = {};
39
+ for (const [key, val] of Object.entries(props)) {
40
+ let node = jsonTypeToZod(val);
41
+ if (!required.has(key)) node = node.optional();
42
+ shape[key] = node;
43
+ }
44
+ return z.object(shape).passthrough();
45
+ }
46
+ return z.unknown();
47
+ }
48
+
49
+ function parametersToZodShape(parameters: JsonSchema | undefined): z.ZodRawShape {
50
+ if (!parameters || parameters['type'] !== 'object') return {};
51
+ const props = (parameters['properties'] as Record<string, JsonSchema> | undefined) ?? {};
52
+ const required = new Set((parameters['required'] as string[] | undefined) ?? []);
53
+ const shape: Record<string, z.ZodTypeAny> = {};
54
+ for (const [key, val] of Object.entries(props)) {
55
+ let node = jsonTypeToZod(val);
56
+ if (!required.has(key)) node = node.optional();
57
+ shape[key] = node;
58
+ }
59
+ return shape;
60
+ }
61
+
62
+ function toCallToolResult(value: unknown): CallToolResult {
63
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
64
+ return { content: [{ type: 'text', text }] };
65
+ }
66
+
67
+ function toCallToolError(err: unknown): CallToolResult {
68
+ const message = err instanceof Error ? err.message : String(err);
69
+ return { content: [{ type: 'text', text: message }], isError: true };
70
+ }
71
+
72
+ /**
73
+ * Map Ziggs `ToolDefinition`s to Claude Agent SDK MCP tools. Each handler routes
74
+ * execution through `eff({ kind: 'tool-call' })` — the shared executor (ZIG-775).
75
+ */
76
+ export function buildSdkToolDefs(
77
+ toolDefs: ToolDefinition[],
78
+ getContext: () => ClaudeSdkToolContext,
79
+ ): Array<SdkMcpToolDefinition> {
80
+ return toolDefs.map((def) => {
81
+ const fn = def.schema.function;
82
+ const name = String(fn['name'] ?? '');
83
+ const description = String(fn['description'] ?? name);
84
+ const shape = parametersToZodShape(fn['parameters'] as JsonSchema | undefined);
85
+ const inputSchema = Object.keys(shape).length > 0
86
+ ? shape
87
+ : { _: z.record(z.string(), z.unknown()).optional() };
88
+
89
+ return {
90
+ name,
91
+ description,
92
+ inputSchema,
93
+ _meta: { 'anthropic/alwaysLoad': true },
94
+ handler: async (args: Record<string, unknown>) => {
95
+ try {
96
+ const ctx = getContext();
97
+ const payload = args._ != null && Object.keys(args).length === 1
98
+ ? (args._ as Record<string, unknown>)
99
+ : args;
100
+ const eff = ctx.eff;
101
+ if (!eff) return toCallToolError(`tool "${name}": missing eff in tool context`);
102
+ const sessionId = ctx.chatId ?? ctx.sessionId;
103
+ if (!sessionId) return toCallToolError(`tool "${name}": missing sessionId in tool context`);
104
+ const r = await eff({
105
+ kind: 'tool-call',
106
+ sessionId,
107
+ name,
108
+ args: payload ?? {},
109
+ senderId: ctx.senderId,
110
+ senderType: ctx.senderType,
111
+ onBehalfOfAgreementId: ctx.onBehalfOfAgreementId as string | undefined,
112
+ runId: ctx.runId,
113
+ });
114
+ if (r.ok) return toCallToolResult(r.result);
115
+ return toCallToolError(r.error ?? `tool "${name}" failed`);
116
+ } catch (err) {
117
+ return toCallToolError(err);
118
+ }
119
+ },
120
+ };
121
+ });
122
+ }
123
+
124
+ export function buildZiggsSdkMcpServer(
125
+ toolDefs: ToolDefinition[],
126
+ getContext: () => ClaudeSdkToolContext,
127
+ options: { name?: string; instructions?: string } = {},
128
+ ) {
129
+ return createSdkMcpServer({
130
+ name: options.name ?? 'ziggs',
131
+ version: '1.0.0',
132
+ instructions: options.instructions,
133
+ tools: buildSdkToolDefs(toolDefs, getContext),
134
+ alwaysLoad: true,
135
+ });
136
+ }
@@ -1,7 +1,9 @@
1
1
  import type { AgentConfig, DefineAgentInput, Workflow } from './types.js';
2
2
  import { validateWorkflow } from './runtime/validateWorkflow.js';
3
3
  import { injectKickoffDefault } from './runtime/kickoff.js';
4
+ import { deriveRespondsToProposals } from './runtime/deriveRespondsToProposals.js';
4
5
  import { runtimeLog } from './shared/runtimeLog.js';
6
+ import { DEFAULT_MODEL } from './AgentHost.js';
5
7
 
6
8
  /**
7
9
  * Validates the workflow synchronously, resolves merged tools and model, and
@@ -38,11 +40,14 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
38
40
  ...(Array.isArray(input.services?.tools) ? input.services!.tools! : []),
39
41
  ];
40
42
 
43
+ // Model resolution, most-specific-first: a per-agent pin, then the
44
+ // fleet-wide `LLM_MODEL` lever, then the single `DEFAULT_MODEL` fallback (the
45
+ // ONE place the default lives — see AgentHost). The id also picks the provider
46
+ // (claude-* → Anthropic). No agent hardcodes its own fallback.
41
47
  const model =
42
48
  input.services?.llm?.model ||
43
49
  process.env.LLM_MODEL ||
44
- process.env.OPENAI_MODEL ||
45
- 'gpt-5.4';
50
+ DEFAULT_MODEL;
46
51
 
47
52
  // Strip the structural fields we already consumed; pass through everything
48
53
  // else (operatorKey, taskTools, custom keys consumed by AgentHost).
@@ -61,6 +66,8 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
61
66
  tools,
62
67
  model,
63
68
  workflow,
69
+ respondsToProposals:
70
+ input.respondsToProposals ?? deriveRespondsToProposals(workflow),
64
71
  ...(input.services ? { services: input.services } : {}),
65
72
  ...(taskTools !== undefined ? { taskTools } : {}),
66
73
  ...rest,
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Foundation:
5
5
  * • Outcome union — discriminated, hybrid (closed core + extension)
6
- * • Workflow shape — explicit kind: 'parked' | 'thinking'
6
+ * • Workflow shape — explicit kind: 'parked' | 'thinking' | 'mechanical'
7
7
  * • Visible defaults — thinkingDefaults({ initial }) spread by author
8
8
  * • Validation at load — defineAgent throws on hard problems, warns on soft
9
9
  */
@@ -11,8 +11,10 @@
11
11
  export { defineAgent } from './defineAgent.js';
12
12
  export { createMcpAgent, resolveZiggsGatewayTarget, wrapGatewayFetch, ZiggsGatewayError } from './adapters/McpAdapter.js';
13
13
  export type { CreateMcpAgentOptions, McpAgentHandle, McpTransportConfig } from './adapters/McpAdapter.js';
14
- export { AgentHost, createAgent, createAgentPool } from './AgentHost.js';
14
+ export { AgentHost, createAgent, createAgentPool, DEFAULT_MODEL } from './AgentHost.js';
15
15
  export { Agent } from './agent/Agent.js';
16
+ export { ClaudeSdkAgent } from './agent/ClaudeSdkAgent.js';
17
+ export type { CognitionKind } from './agent/ClaudeSdkAgent.js';
16
18
  export type { TickInput, TickOutput, AgentOptions } from './agent/Agent.js';
17
19
  export type { SessionResolver, AgentHostOptions } from './AgentHost.js';
18
20
  export { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
@@ -26,17 +28,22 @@ export { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
26
28
  // Runtime primitives
27
29
  export { AgentMachine, type TransitionEvent } from './runtime/AgentMachine.js';
28
30
  export { runTurn } from './runtime/runTurn.js';
31
+ export { runMechanical } from './runtime/runMechanical.js';
29
32
  export { PromptBuilder } from './runtime/PromptBuilder.js';
30
- export { thinkingDefaults, defineThinkingState, DEFAULT_WAIT_PROMPT } from './runtime/defaults.js';
33
+ export { thinkingDefaults, defineThinkingState, defineMechanicalState, DEFAULT_WAIT_PROMPT } from './runtime/defaults.js';
31
34
  export { KICKOFF_STATE, injectKickoffDefault, buildKickoffState } from './runtime/kickoff.js';
32
35
  export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
36
+ export {
37
+ deriveRespondsToProposals,
38
+ statesRespondToProposals,
39
+ } from './runtime/deriveRespondsToProposals.js';
33
40
  export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
34
41
 
35
42
  // Types
36
43
  export type {
37
44
  Outcome, OutcomeKind,
38
45
  Ctx, Identity, HistoryEntry, ToolResultEntry,
39
- Workflow, State, ParkedState, ThinkingState,
46
+ Workflow, State, ParkedState, ThinkingState, MechanicalState,
40
47
  Action, Transition, PromptDef, ActionPrompt, Produces, ProducesFn,
41
48
  AgreementRef, TaskRef, ProposalRef,
42
49
  AgentConfig, DefineAgentInput, ServicesConfig,
@@ -99,7 +106,6 @@ export {
99
106
  WebSocketClient,
100
107
  MessagesClient,
101
108
  ArtifactsClient,
102
- ScopeClient,
103
109
  AgentSearchClient,
104
110
  getBackendUrl,
105
111
  getWebSocketUrl,
@@ -2,7 +2,9 @@ import type { Ctx, Outcome, Workflow, Identity, EffectHandler } from '../types.j
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  import { applyOutcomeToCtx, outcomeFromEvent, type RawEvent } from './buildOutcome.js';
4
4
  import type { runTurn as RunTurnFn } from './runTurn.js';
5
+ import { runMechanical } from './runMechanical.js';
5
6
  import { PromptBuilder } from './PromptBuilder.js';
7
+ import { bootstrapCtx } from './bootstrapCtx.js';
6
8
  import type { MemoryStore } from '../memory/MemoryStore.js';
7
9
  import type { ToolManager } from '../tools/ToolManager.js';
8
10
 
@@ -16,9 +18,20 @@ export type TransitionEvent =
16
18
  | { kind: 'transition'; fromState: string; toState: string; outcomeKind: string; depth: number }
17
19
  | { kind: 'no-match'; state: string; outcomeKind: string; depth: number }
18
20
  | { kind: 'depth-guard'; state: string; depth: number }
21
+ | { kind: 'turn-budget-guard'; state: string; turns: number }
19
22
  | { kind: 'error-escape'; fromState: string; toState: string }
20
23
  | { kind: 'timeout-unhandled'; state: string; waitedMs: number };
21
24
 
25
+ /**
26
+ * Max thinking turns a session may run without any human (USER) input before it
27
+ * is treated as a runaway loop and parked (ZIG-768). The depth-guard only bounds
28
+ * re-entries within a single event chain; this bounds turns ACROSS events, which
29
+ * is how a supervising session ran 436 turns in a day. The counter resets on
30
+ * every USER frame, so human-driven conversations are never affected — only
31
+ * autonomous agent↔agent loops accumulate toward it.
32
+ */
33
+ const DEFAULT_MAX_TURNS_WITHOUT_USER_INPUT = 100;
34
+
22
35
  interface MachineInput {
23
36
  identity: Identity;
24
37
  /** Server-provided ctx for an existing session. If omitted, a fresh ctx is built. */
@@ -40,6 +53,8 @@ interface MachineOptions {
40
53
  input: MachineInput;
41
54
  /** Optional tap for FSM transition events — called synchronously on each transition. */
42
55
  transitionTap?: (event: TransitionEvent) => void;
56
+ /** Runaway-loop backstop: max thinking turns without human input before parking. Default 100. */
57
+ maxTurnsWithoutUserInput?: number;
43
58
  }
44
59
 
45
60
  interface IncomingFrame {
@@ -81,12 +96,15 @@ export class AgentMachine {
81
96
  _lastUserSenderId: string | null = null;
82
97
  _stateTimeoutUnhandled = false;
83
98
  _transitionTap: ((event: TransitionEvent) => void) | undefined;
99
+ /** Thinking turns run since the last human (USER) frame. Reset on USER input. */
100
+ _turnsSinceUserInput = 0;
101
+ _maxTurnsWithoutUserInput: number;
84
102
 
85
103
  get llmCalls(): number { return this._llmCalls; }
86
104
  get tokens(): { total: number; input: number; output: number } { return this._tokens; }
87
105
  get stateTimeoutUnhandled(): boolean { return this._stateTimeoutUnhandled; }
88
106
 
89
- constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap }: MachineOptions) {
107
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput }: MachineOptions) {
90
108
  this.workflow = workflow;
91
109
  this.executionCore = executionCore;
92
110
  this.eff = eff;
@@ -94,28 +112,9 @@ export class AgentMachine {
94
112
  this.promptBuilder = promptBuilder;
95
113
  this.memory = memory;
96
114
  this._transitionTap = transitionTap;
115
+ this._maxTurnsWithoutUserInput = maxTurnsWithoutUserInput ?? DEFAULT_MAX_TURNS_WITHOUT_USER_INPUT;
97
116
  this.state = input.state || workflow.initial;
98
- this.ctx = input.ctx
99
- ? {
100
- ...input.ctx,
101
- identity: input.identity,
102
- // Sessions persisted before pendingSubtasks/collectedResults existed
103
- // (DynamoDB rows live up to SESSION_TTL_SECONDS = 24h) restore without
104
- // these fields. Default them ONCE here — nowhere else.
105
- pendingSubtasks: input.ctx.pendingSubtasks ?? {},
106
- collectedResults: input.ctx.collectedResults ?? [],
107
- }
108
- : {
109
- identity: input.identity,
110
- activeAgreementId: null,
111
- activeTaskId: null,
112
- pendingProposalAgreementId: null,
113
- delegatedAgreementIds: [],
114
- pendingSubtasks: {},
115
- collectedResults: [],
116
- toolResults: [],
117
- lastOutcome: { kind: 'enter' },
118
- };
117
+ this.ctx = bootstrapCtx(input.identity, input.ctx);
119
118
  }
120
119
 
121
120
  getSnapshot(): MachineSnapshot {
@@ -168,6 +167,8 @@ export class AgentMachine {
168
167
  this._lastIncomingEvent = ev ?? null;
169
168
  if (ev?.senderId && String(ev?.senderType ?? '').toUpperCase() === 'USER') {
170
169
  this._lastUserSenderId = String(ev.senderId);
170
+ // Human input is real progress — reset the runaway-loop budget (ZIG-768).
171
+ this._turnsSinceUserInput = 0;
171
172
  }
172
173
  runtimeLog.debug(
173
174
  'FSM',
@@ -243,6 +244,70 @@ export class AgentMachine {
243
244
  return;
244
245
  }
245
246
 
247
+ if (sd.kind === 'mechanical') {
248
+ if (depth >= 12) {
249
+ runtimeLog.warn(
250
+ 'AgentMachine',
251
+ `state=${this.state} runaway mechanical loop (depth=${depth}); parking`,
252
+ );
253
+ this._transitionTap?.({ kind: 'depth-guard', state: this.state, depth });
254
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
255
+ this._notify();
256
+ return;
257
+ }
258
+ if (this._turnsSinceUserInput >= this._maxTurnsWithoutUserInput) {
259
+ runtimeLog.warn(
260
+ 'AgentMachine',
261
+ `state=${this.state} runaway loop: ${this._turnsSinceUserInput} turns without user input (cap ${this._maxTurnsWithoutUserInput}); parking`,
262
+ );
263
+ this._transitionTap?.({ kind: 'turn-budget-guard', state: this.state, turns: this._turnsSinceUserInput });
264
+ await this._sendFallbackIfUserWaiting('I got stuck in a loop and paused. Please send another message to continue.');
265
+ this._notify();
266
+ return;
267
+ }
268
+ runtimeLog.debug('FSM', `_step mechanical state=${this.state} — run tool`);
269
+ this._running = true;
270
+ try {
271
+ const { outcome: turnOutcome, toolResults, llmCalls, tokens } = await runMechanical({
272
+ stateId: this.state,
273
+ state: sd,
274
+ ctx: this.ctx,
275
+ eff: this.eff,
276
+ memory: this.memory,
277
+ incomingEvent: this._lastIncomingEvent,
278
+ });
279
+ this.ctx.toolResults = toolResults;
280
+ this._llmCalls += llmCalls;
281
+ this._turnsSinceUserInput += 1;
282
+ this._tokens.total += tokens.total;
283
+ this._tokens.input += tokens.input;
284
+ this._tokens.output += tokens.output;
285
+ applyOutcomeToCtx(this.ctx, turnOutcome);
286
+ this._running = false;
287
+ this._notify();
288
+ await this._step(turnOutcome, depth + 1);
289
+ } catch (error: unknown) {
290
+ runtimeLog.error('AgentMachine', `mechanical state=${this.state} error: ${(error as Error)?.message || error}`);
291
+ const errOutcome: Outcome = {
292
+ kind: 'error',
293
+ source: 'unknown',
294
+ cause: (error as Error)?.message || String(error),
295
+ retryable: false,
296
+ };
297
+ applyOutcomeToCtx(this.ctx, errOutcome);
298
+ this._running = false;
299
+ this._notify();
300
+ const fallback = this._evaluateTransitions(errOutcome);
301
+ if (fallback && fallback !== this.state) {
302
+ this.state = fallback;
303
+ this._notify();
304
+ } else {
305
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
306
+ }
307
+ }
308
+ return;
309
+ }
310
+
246
311
  // A thinking turn that yields an outcome no transition handles must not
247
312
  // silently re-enter the same state and burn another LLM call (ZIG-303).
248
313
  // Explicit self-loops still match (target is non-null, even if target === state).
@@ -282,6 +347,22 @@ export class AgentMachine {
282
347
  this._notify();
283
348
  return;
284
349
  }
350
+ // Cross-event runaway guard (ZIG-768). The depth-guard above only bounds
351
+ // one event chain; a supervising session that keeps waking on agent/system
352
+ // messages re-enters at depth 0 each time and never trips it. Bound the
353
+ // total thinking turns since the last human input instead — a runaway
354
+ // agent↔agent loop climbs toward the cap while a human conversation resets
355
+ // it on every user message.
356
+ if (this._turnsSinceUserInput >= this._maxTurnsWithoutUserInput) {
357
+ runtimeLog.warn(
358
+ 'AgentMachine',
359
+ `state=${this.state} runaway loop: ${this._turnsSinceUserInput} turns without user input (cap ${this._maxTurnsWithoutUserInput}); parking`,
360
+ );
361
+ this._transitionTap?.({ kind: 'turn-budget-guard', state: this.state, turns: this._turnsSinceUserInput });
362
+ await this._sendFallbackIfUserWaiting('I got stuck in a loop and paused. Please send another message to continue.');
363
+ this._notify();
364
+ return;
365
+ }
285
366
  runtimeLog.debug('FSM', `_step thinking state=${this.state} — run turn`);
286
367
 
287
368
  // Thinking: run a turn, produce an outcome, re-step.
@@ -302,6 +383,7 @@ export class AgentMachine {
302
383
 
303
384
  this.ctx.toolResults = toolResults;
304
385
  this._llmCalls += llmCalls;
386
+ this._turnsSinceUserInput += 1;
305
387
  this._tokens.total += tokens.total;
306
388
  this._tokens.input += tokens.input;
307
389
  this._tokens.output += tokens.output;
@@ -46,11 +46,21 @@ export interface PromptBuilderOptions {
46
46
  description?: string;
47
47
  specialization?: string | null;
48
48
  maxHistoryEntries?: number;
49
+ /**
50
+ * Token budget for the <history> block. History is included newest-first up
51
+ * to this many (estimated) tokens, so a few large turns can't blow the prompt
52
+ * up to tens of thousands of input tokens (ZIG-769). maxHistoryEntries still
53
+ * applies as a secondary hard ceiling.
54
+ */
55
+ maxHistoryTokens?: number;
49
56
  historyFormatter?: HistoryFormatterI;
50
57
  agreementFormatter?: AgreementFormatterI;
51
58
  logger?: LoggerI | null | { warn: (msg: string) => void };
52
59
  }
53
60
 
61
+ /** Rough chars-per-token ratio for budgeting (English/JSON averages ~4). */
62
+ const CHARS_PER_TOKEN = 4;
63
+
54
64
  /**
55
65
  * Builds the XML prompt for a thinking-state turn:
56
66
  * <agent><self/><world/><decision_schema/></agent>
@@ -63,6 +73,7 @@ export class PromptBuilder {
63
73
  description: string;
64
74
  specialization: string | null;
65
75
  maxHistoryEntries: number;
76
+ maxHistoryTokens: number;
66
77
  historyFormatter: HistoryFormatterI;
67
78
  agreementFormatter: AgreementFormatterI;
68
79
  logger: LoggerI | null;
@@ -71,11 +82,17 @@ export class PromptBuilder {
71
82
  this.description = options.description || 'A capable agent ready to assist';
72
83
  this.specialization = options.specialization ?? null;
73
84
  this.maxHistoryEntries = options.maxHistoryEntries ?? 50;
85
+ this.maxHistoryTokens = options.maxHistoryTokens ?? 8000;
74
86
  this.historyFormatter = options.historyFormatter || new HistoryFormatter();
75
87
  this.agreementFormatter = (options.agreementFormatter || new AgreementFormatter()) as AgreementFormatterI;
76
88
  this.logger = options.logger as LoggerI | null || null;
77
89
  }
78
90
 
91
+ // Section order is deliberate and cache-stable (ZIG-769): <self> (identity,
92
+ // state, tools) is stable per agent+state and comes first, so OpenAI automatic
93
+ // prompt caching can reuse it as a prefix across a conversation's turns. All
94
+ // volatile content (the incoming event, history, and currentTime) lives later
95
+ // in <world>, so it never invalidates that cacheable prefix.
79
96
  buildFromState(args: BuildArgs): string {
80
97
  const { statePrompt, actions, serverContext, incomingEvent, definition, stateId, ctx, omitMessageFromRespond } = args;
81
98
  const ctxObj = serverContext?.context || {};
@@ -270,16 +287,36 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
270
287
  }
271
288
 
272
289
  _renderHistory(history: AnyObj[] | undefined, agentId: string | null): string {
273
- let entries = history || [];
274
- let truncNote = '';
275
- if (entries.length > this.maxHistoryEntries) {
276
- truncNote = `[Showing last ${this.maxHistoryEntries} of ${entries.length} entries]\n\n`;
277
- entries = entries.slice(-this.maxHistoryEntries);
290
+ const all = history || [];
291
+ const charBudget = this.maxHistoryTokens * CHARS_PER_TOKEN;
292
+
293
+ // Include entries newest-first, bounded by BOTH a token budget and the
294
+ // entry-count ceiling. The newest entry is always kept even if it alone
295
+ // exceeds the budget, so the most recent turn is never dropped.
296
+ let startIdx = all.length;
297
+ let usedChars = 0;
298
+ for (let i = all.length - 1; i >= 0; i--) {
299
+ if (all.length - i > this.maxHistoryEntries) break;
300
+ const size = this._estimateEntryChars(all[i]);
301
+ if (startIdx < all.length && usedChars + size > charBudget) break;
302
+ usedChars += size;
303
+ startIdx = i;
278
304
  }
305
+
306
+ const entries = all.slice(startIdx);
307
+ const dropped = all.length - entries.length;
308
+ const truncNote = dropped > 0
309
+ ? `[Showing last ${entries.length} of ${all.length} entries — older turns dropped to fit the context budget]\n\n`
310
+ : '';
279
311
  const formatted = this.historyFormatter.format(entries, agentId ?? '', {});
280
312
  return `<history>\n${truncNote}${formatted}\n</history>`;
281
313
  }
282
314
 
315
+ /** Rough character size of one history entry, used for the token budget. */
316
+ _estimateEntryChars(entry: AnyObj): number {
317
+ try { return JSON.stringify(entry)?.length ?? 0; } catch { return 0; }
318
+ }
319
+
283
320
  _renderAgreements(agreements: AnyObj[]): string {
284
321
  const formatted = this.agreementFormatter.format(agreements || []);
285
322
  return `<agreements>\n${formatted}\n</agreements>`;
@@ -0,0 +1,39 @@
1
+ import type { Ctx, Identity } from '../types.js';
2
+
3
+ /**
4
+ * The one place a per-lane `Ctx` is constructed.
5
+ *
6
+ * Both cognitions bootstrap the same Ctx: the FSM (`AgentMachine` constructor)
7
+ * and the Claude Agent SDK brain (`ClaudeSdkAgent.tick`). Before this, each
8
+ * carried its own copy of the fresh-Ctx literal and the restore-path defaulting,
9
+ * and they had already drifted (the "default them ONCE here" comment in
10
+ * AgentMachine no longer held). Keep it here so the shape lives in a single spot.
11
+ *
12
+ * - No `existing` → a fresh Ctx for a brand-new session.
13
+ * - `existing` present → restore it, re-stamping `identity` and defaulting the
14
+ * fields that sessions persisted before they existed may lack. Sessions live
15
+ * up to SESSION_TTL_SECONDS (24h) in DynamoDB, so an old row can come back
16
+ * without `pendingSubtasks` / `collectedResults`; default them here, nowhere
17
+ * else.
18
+ */
19
+ export function bootstrapCtx(identity: Identity, existing?: Ctx | null): Ctx {
20
+ if (existing) {
21
+ return {
22
+ ...existing,
23
+ identity,
24
+ pendingSubtasks: existing.pendingSubtasks ?? {},
25
+ collectedResults: existing.collectedResults ?? [],
26
+ };
27
+ }
28
+ return {
29
+ identity,
30
+ activeAgreementId: null,
31
+ activeTaskId: null,
32
+ pendingProposalAgreementId: null,
33
+ delegatedAgreementIds: [],
34
+ pendingSubtasks: {},
35
+ collectedResults: [],
36
+ toolResults: [],
37
+ lastOutcome: { kind: 'enter' },
38
+ };
39
+ }
@@ -1,4 +1,4 @@
1
- import type { Action, Transition, ThinkingState } from '../types.js';
1
+ import type { Action, Ctx, MechanicalState, Outcome, Produces, Transition, ThinkingState } from '../types.js';
2
2
 
3
3
  /**
4
4
  * Defaults that workflow authors spread into thinking states explicitly.
@@ -73,3 +73,14 @@ export function defineThinkingState(
73
73
  transitions: [...state.transitions, ...dflt.transitions],
74
74
  };
75
75
  }
76
+
77
+ /** ZIG-764: mechanical state — one tool call, zero LLM turns. */
78
+ export function defineMechanicalState(state: {
79
+ tool: string;
80
+ args?: Record<string, unknown>;
81
+ buildArgs?: (ctx: Ctx, outcome: Outcome) => Record<string, unknown>;
82
+ produces: Produces;
83
+ transitions: Transition[];
84
+ }): MechanicalState {
85
+ return { kind: 'mechanical', ...state };
86
+ }
@@ -0,0 +1,35 @@
1
+ import type { State, ThinkingState, Workflow } from '../types.js';
2
+
3
+ /**
4
+ * ZIG-826: true when the workflow exposes `agreement_respond` on a thinking
5
+ * action — the agent can approve/reject inbound proposals directed at it.
6
+ * Kickoff-only states (proposer-side approval → task_spawn) do not count.
7
+ */
8
+ export function deriveRespondsToProposals(workflow: Workflow): boolean {
9
+ if (!workflow?.states) return false;
10
+ for (const state of Object.values(workflow.states)) {
11
+ if (state.kind !== 'thinking') continue;
12
+ const actions = (state as ThinkingState).actions;
13
+ if (!actions) continue;
14
+ for (const action of Object.values(actions)) {
15
+ if (action?.tool === 'agreement_respond') return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ /** Scan custom states (pre-defineAgent) for agreement_respond actions. */
22
+ export function statesRespondToProposals(
23
+ states: Record<string, State> | undefined,
24
+ ): boolean {
25
+ if (!states) return false;
26
+ for (const state of Object.values(states)) {
27
+ if (state.kind !== 'thinking') continue;
28
+ const actions = (state as ThinkingState).actions;
29
+ if (!actions) continue;
30
+ for (const action of Object.values(actions)) {
31
+ if (action?.tool === 'agreement_respond') return true;
32
+ }
33
+ }
34
+ return false;
35
+ }