@ziggs-ai/agent-sdk 0.1.10 → 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.
Files changed (35) hide show
  1. package/package.json +6 -3
  2. package/src/AgentHost.ts +347 -28
  3. package/src/adapters/AnthropicAdapter.ts +238 -0
  4. package/src/adapters/McpAdapter.ts +391 -15
  5. package/src/adapters/OpenAIAdapter.ts +20 -8
  6. package/src/adapters/index.ts +1 -0
  7. package/src/agent/Agent.ts +13 -2
  8. package/src/agent/ClaudeSdkAgent.ts +401 -0
  9. package/src/agent/claudeCognition.ts +139 -0
  10. package/src/agent/claudeSdkProtocol.ts +27 -0
  11. package/src/agent/claudeSdkTools.ts +81 -0
  12. package/src/agent/hostedAgentConnection.ts +41 -0
  13. package/src/agent/toolManagerToSdkMcp.ts +136 -0
  14. package/src/defineAgent.ts +22 -7
  15. package/src/index.ts +14 -5
  16. package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
  17. package/src/runtime/AgentMachine.ts +143 -23
  18. package/src/runtime/PromptBuilder.ts +62 -6
  19. package/src/runtime/bootstrapCtx.ts +39 -0
  20. package/src/runtime/buildOutcome.ts +51 -6
  21. package/src/runtime/defaults.ts +12 -1
  22. package/src/runtime/deriveRespondsToProposals.ts +35 -0
  23. package/src/runtime/kickoff.ts +117 -0
  24. package/src/runtime/runMechanical.ts +106 -0
  25. package/src/runtime/runTurn.ts +236 -62
  26. package/src/runtime/validateWorkflow.ts +55 -15
  27. package/src/server/ConnectionPool.ts +56 -5
  28. package/src/server/InboxCatchUp.ts +37 -0
  29. package/src/server/StateTimers.ts +85 -0
  30. package/src/server/ZiggsEffectHandler.ts +198 -180
  31. package/src/server/agreements/AgreementService.ts +3 -3
  32. package/src/server/tasks/TaskService.ts +15 -3
  33. package/src/server/tools/tier1/protocolRunner.ts +221 -3
  34. package/src/server/tools/tier1/protocolTools.ts +19 -4
  35. package/src/types.ts +105 -29
@@ -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,7 +18,19 @@ 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 }
19
- | { kind: 'error-escape'; fromState: string; toState: string };
21
+ | { kind: 'turn-budget-guard'; state: string; turns: number }
22
+ | { kind: 'error-escape'; fromState: string; toState: string }
23
+ | { kind: 'timeout-unhandled'; state: string; waitedMs: number };
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;
20
34
 
21
35
  interface MachineInput {
22
36
  identity: Identity;
@@ -39,6 +53,8 @@ interface MachineOptions {
39
53
  input: MachineInput;
40
54
  /** Optional tap for FSM transition events — called synchronously on each transition. */
41
55
  transitionTap?: (event: TransitionEvent) => void;
56
+ /** Runaway-loop backstop: max thinking turns without human input before parking. Default 100. */
57
+ maxTurnsWithoutUserInput?: number;
42
58
  }
43
59
 
44
60
  interface IncomingFrame {
@@ -78,12 +94,17 @@ export class AgentMachine {
78
94
  _llmCalls = 0;
79
95
  _tokens = { total: 0, input: 0, output: 0 };
80
96
  _lastUserSenderId: string | null = null;
97
+ _stateTimeoutUnhandled = false;
81
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;
82
102
 
83
103
  get llmCalls(): number { return this._llmCalls; }
84
104
  get tokens(): { total: number; input: number; output: number } { return this._tokens; }
105
+ get stateTimeoutUnhandled(): boolean { return this._stateTimeoutUnhandled; }
85
106
 
86
- constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap }: MachineOptions) {
107
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput }: MachineOptions) {
87
108
  this.workflow = workflow;
88
109
  this.executionCore = executionCore;
89
110
  this.eff = eff;
@@ -91,19 +112,9 @@ export class AgentMachine {
91
112
  this.promptBuilder = promptBuilder;
92
113
  this.memory = memory;
93
114
  this._transitionTap = transitionTap;
115
+ this._maxTurnsWithoutUserInput = maxTurnsWithoutUserInput ?? DEFAULT_MAX_TURNS_WITHOUT_USER_INPUT;
94
116
  this.state = input.state || workflow.initial;
95
- this.ctx = input.ctx
96
- ? { ...input.ctx, identity: input.identity }
97
- : {
98
- identity: input.identity,
99
- activeAgreementId: null,
100
- activeTaskId: null,
101
- pendingProposalAgreementId: null,
102
- delegatedAgreementIds: [],
103
- delegatedTaskIds: [],
104
- toolResults: [],
105
- lastOutcome: { kind: 'enter' },
106
- };
117
+ this.ctx = bootstrapCtx(input.identity, input.ctx);
107
118
  }
108
119
 
109
120
  getSnapshot(): MachineSnapshot {
@@ -156,6 +167,8 @@ export class AgentMachine {
156
167
  this._lastIncomingEvent = ev ?? null;
157
168
  if (ev?.senderId && String(ev?.senderType ?? '').toUpperCase() === 'USER') {
158
169
  this._lastUserSenderId = String(ev.senderId);
170
+ // Human input is real progress — reset the runaway-loop budget (ZIG-768).
171
+ this._turnsSinceUserInput = 0;
159
172
  }
160
173
  runtimeLog.debug(
161
174
  'FSM',
@@ -172,13 +185,18 @@ export class AgentMachine {
172
185
  // message, which triggers another resource_changed, and so on.
173
186
  if (ev && outcome.kind === 'enter') return;
174
187
 
175
- if (
176
- outcome.kind === 'subtask-finished' &&
177
- ev?.type === 'task_result' &&
178
- ev?.result?.taskId &&
179
- this.ctx.delegatedTaskIds.includes(ev.result.taskId)
180
- ) {
181
- // Already correctly classified — no override needed.
188
+ // Proposer detection must read pendingProposalAgreementId BEFORE
189
+ // applyOutcomeToCtx clears it on resolution (buildOutcome.ts). Wire frames
190
+ // carry no trustworthy proposer field — agreement_lifecycle drops `parties`
191
+ // in normalizeIncomingEvent, and parties.creator is a principal id, not an
192
+ // agentId (backend ZIG-272) — so the SDK's own "I made this proposal"
193
+ // memory is the signal. Side effect we rely on: the first resolution clears
194
+ // the pending id, so duplicate approval frames compute proposedByMe=false
195
+ // and can never double-kickoff.
196
+ if (outcome.kind === 'proposal-resolved') {
197
+ outcome.proposedByMe =
198
+ this.ctx.pendingProposalAgreementId !== null &&
199
+ outcome.proposal.agreementId === this.ctx.pendingProposalAgreementId;
182
200
  }
183
201
 
184
202
  applyOutcomeToCtx(this.ctx, outcome);
@@ -199,6 +217,24 @@ export class AgentMachine {
199
217
  this._notify();
200
218
  }
201
219
 
220
+ // A state-timeout only enters at depth 0 (host-armed frame via _handleFrame;
221
+ // turns cannot produce it — validateWorkflow rejects produces:'state-timeout').
222
+ // No matching transition = the author declared timeoutMs but no route: park
223
+ // loudly and tell the host not to re-arm (re-arming would error every
224
+ // timeoutMs forever). Next real event still recovers the lane. A matched
225
+ // transition (target !== null) — including an explicit self-loop — is
226
+ // "handled" and re-arms as a heartbeat.
227
+ if (outcome.kind === 'state-timeout' && depth === 0 && !target) {
228
+ this._stateTimeoutUnhandled = true;
229
+ runtimeLog.error(
230
+ 'AgentMachine',
231
+ `state=${this.state} timed out after ${outcome.waitedMs}ms but no transition matches { kind: 'state-timeout' } — parking without a timer. Add a state-timeout transition to "${this.state}" or remove timeoutMs.`,
232
+ );
233
+ this._transitionTap?.({ kind: 'timeout-unhandled', state: this.state, waitedMs: outcome.waitedMs });
234
+ this._notify();
235
+ return;
236
+ }
237
+
202
238
  const sd = this.workflow.states[this.state];
203
239
  if (!sd) return;
204
240
  if (sd.kind === 'parked') {
@@ -208,6 +244,70 @@ export class AgentMachine {
208
244
  return;
209
245
  }
210
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
+
211
311
  // A thinking turn that yields an outcome no transition handles must not
212
312
  // silently re-enter the same state and burn another LLM call (ZIG-303).
213
313
  // Explicit self-loops still match (target is non-null, even if target === state).
@@ -217,7 +317,7 @@ export class AgentMachine {
217
317
  fromState === this.state &&
218
318
  !target
219
319
  ) {
220
- if (this._escapeToInitialParked(outcome, fromState)) return;
320
+ if (await this._escapeToInitialParked(outcome, fromState)) return;
221
321
  runtimeLog.warn(
222
322
  'AgentMachine',
223
323
  `state=${fromState} outcome.kind=${(outcome as Record<string, unknown>).kind} matched no transition; parking (depth=${depth})`,
@@ -247,6 +347,22 @@ export class AgentMachine {
247
347
  this._notify();
248
348
  return;
249
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
+ }
250
366
  runtimeLog.debug('FSM', `_step thinking state=${this.state} — run turn`);
251
367
 
252
368
  // Thinking: run a turn, produce an outcome, re-step.
@@ -267,6 +383,7 @@ export class AgentMachine {
267
383
 
268
384
  this.ctx.toolResults = toolResults;
269
385
  this._llmCalls += llmCalls;
386
+ this._turnsSinceUserInput += 1;
270
387
  this._tokens.total += tokens.total;
271
388
  this._tokens.input += tokens.input;
272
389
  this._tokens.output += tokens.output;
@@ -299,7 +416,7 @@ export class AgentMachine {
299
416
  }
300
417
 
301
418
  /** Error with no author-defined escape: park at workflow.initial if it is parked. */
302
- _escapeToInitialParked(outcome: Outcome, fromState: string): boolean {
419
+ async _escapeToInitialParked(outcome: Outcome, fromState: string): Promise<boolean> {
303
420
  if (outcome.kind !== 'error') return false;
304
421
  const initial = this.workflow.initial;
305
422
  const initialSd = this.workflow.states[initial];
@@ -312,6 +429,9 @@ export class AgentMachine {
312
429
  );
313
430
  this._transitionTap?.({ kind: 'error-escape', fromState, toState: initial });
314
431
  this.state = initial;
432
+ // The turn failed and no workflow transition handled it — a waiting human
433
+ // got nothing, so apologize before parking (same text as the other parks).
434
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
315
435
  this._notify();
316
436
  return true;
317
437
  }
@@ -1,4 +1,4 @@
1
- import type { Action, Ctx, PromptDef } from '../types.js';
1
+ import type { Action, CollectedSubtaskResult, Ctx, PromptDef } from '../types.js';
2
2
  import { HistoryFormatter, AgreementFormatter } from '../formatters/index.js';
3
3
  import { validateContext } from '../cognition/validateContext.js';
4
4
  import type { RawEvent } from './buildOutcome.js';
@@ -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 || {};
@@ -151,6 +168,11 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
151
168
  lines.push('');
152
169
  lines.push(this._renderAgreements(agreements));
153
170
 
171
+ if (ctx?.collectedResults?.length) {
172
+ lines.push('');
173
+ lines.push(this._renderSubtaskResults(ctx.collectedResults));
174
+ }
175
+
154
176
  if (users.length > 0 || otherAgents.length > 0) {
155
177
  lines.push('');
156
178
  lines.push(this._renderRecipients(users, otherAgents, agreements, agentId));
@@ -171,10 +193,24 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
171
193
  if (ctx.activeTaskId) out.activeTaskId = ctx.activeTaskId;
172
194
  if (ctx.pendingProposalAgreementId) out.pendingProposalAgreementId = ctx.pendingProposalAgreementId;
173
195
  if (ctx.delegatedAgreementIds?.length) out.delegatedAgreementIds = ctx.delegatedAgreementIds;
196
+ if (Object.keys(ctx.pendingSubtasks ?? {}).length) out.pendingSubtasks = ctx.pendingSubtasks;
174
197
  if (ctx.lastOutcome) out.lastOutcomeKind = ctx.lastOutcome.kind;
175
198
  return `<machine_context>\n${JSON.stringify(out, null, 2)}\n</machine_context>`;
176
199
  }
177
200
 
201
+ _renderSubtaskResults(results: CollectedSubtaskResult[]): string {
202
+ const lines: string[] = ['<subtask_results>'];
203
+ lines.push(`All ${results.length} finished subtask(s) for the current engagement, oldest first. Synthesize from ALL of them — not just the most recent event.`);
204
+ for (const r of results) {
205
+ const payload = r.result == null ? null : typeof r.result === 'string' ? r.result : JSON.stringify(r.result);
206
+ const display = payload && payload.length > 2000 ? payload.slice(0, 2000) + `… [truncated of ${payload.length} chars]` : payload;
207
+ lines.push(`- taskId=${r.taskId} agent=${r.agentId ?? 'unknown'} status=${r.status}`);
208
+ lines.push(` result: ${display ?? '(none)'}`);
209
+ }
210
+ lines.push('</subtask_results>');
211
+ return lines.join('\n');
212
+ }
213
+
178
214
  _renderEvent(event: RawEvent | AnyObj | null, senderMeta: SenderMeta): string {
179
215
  if (!event) return '<event type="none">No incoming event.</event>';
180
216
 
@@ -251,16 +287,36 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
251
287
  }
252
288
 
253
289
  _renderHistory(history: AnyObj[] | undefined, agentId: string | null): string {
254
- let entries = history || [];
255
- let truncNote = '';
256
- if (entries.length > this.maxHistoryEntries) {
257
- truncNote = `[Showing last ${this.maxHistoryEntries} of ${entries.length} entries]\n\n`;
258
- 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;
259
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
+ : '';
260
311
  const formatted = this.historyFormatter.format(entries, agentId ?? '', {});
261
312
  return `<history>\n${truncNote}${formatted}\n</history>`;
262
313
  }
263
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
+
264
320
  _renderAgreements(agreements: AnyObj[]): string {
265
321
  const formatted = this.agreementFormatter.format(agreements || []);
266
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
+ }
@@ -97,6 +97,7 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
97
97
  kind: 'proposal-resolved',
98
98
  action,
99
99
  proposal: { agreementId },
100
+ proposedByMe: false, // real value stamped in AgentMachine._handleFrame from ctx (needs pendingProposalAgreementId, unavailable here)
100
101
  };
101
102
  }
102
103
  return null;
@@ -124,7 +125,7 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
124
125
  const proposal: ProposalRef = agId
125
126
  ? { agreementId: agId, agreement: result as unknown as AgreementRef }
126
127
  : { agreementId: '' };
127
- return { kind: 'proposal-resolved', action: proposalAction as 'approve' | 'reject', proposal };
128
+ return { kind: 'proposal-resolved', action: proposalAction as 'approve' | 'reject', proposal, proposedByMe: false }; // real value stamped in AgentMachine._handleFrame from ctx
128
129
  }
129
130
  // Other lifecycle ops (countered, expired) treated as enter — no FSM action needed here.
130
131
  return null;
@@ -194,6 +195,7 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
194
195
  kind: 'proposal-resolved',
195
196
  action: reason === 'proposal_approved' ? 'approve' : 'reject',
196
197
  proposal,
198
+ proposedByMe: false, // real value stamped in AgentMachine._handleFrame from ctx
197
199
  };
198
200
  }
199
201
  if (resource.kind === 'task-state' && reason === 'subtask_completed') {
@@ -204,6 +206,11 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
204
206
  return null;
205
207
  }
206
208
 
209
+ if (ev.type === 'state-timeout') {
210
+ // Synthetic frame from AgentHost's StateTimers — host-constructed, fields always present.
211
+ return { kind: 'state-timeout', state: ev.state as string, waitedMs: ev.waitedMs as number };
212
+ }
213
+
207
214
  // Only real inbound chat messages should drive the FSM. Backend bookkeeping
208
215
  // (presence/heartbeat, raw `batch` outer frames, etc.) are NOT chat traffic.
209
216
  if (ev.type !== 'message') return null;
@@ -223,6 +230,10 @@ function outcomeRank(o: Outcome): number {
223
230
  case 'proposal-resolved': return 90;
224
231
  case 'subtask-finished': return 80;
225
232
  case 'message-received': return 10;
233
+ // Any real event coalesced into the same batch supersedes the timer —
234
+ // waking to "nothing happened" is moot when something did. Must stay below
235
+ // message-received (10).
236
+ case 'state-timeout': return 1;
226
237
  default: return 50;
227
238
  }
228
239
  }
@@ -390,9 +401,13 @@ function buildOutcomeOfKind(
390
401
  switch (kind) {
391
402
  case 'enter':
392
403
  case 'wait':
393
- case 'timeout':
394
404
  return { kind };
395
405
 
406
+ case 'state-timeout':
407
+ // Runtime-generated only (validateWorkflow rejects produces:'state-timeout').
408
+ // Break-fast backstop if an action somehow declares it.
409
+ throw new Error(`action produced reserved outcome kind "state-timeout" — runtime-generated only`);
410
+
396
411
  case 'message-sent':
397
412
  return { kind, text: primary?.message, receiverId: primary?.receiverId };
398
413
 
@@ -431,7 +446,7 @@ function buildOutcomeOfKind(
431
446
  const proposal: ProposalRef = ag
432
447
  ? { agreementId: ag.agreementId, agreement: ag }
433
448
  : { agreementId: result?.agreementId || '' };
434
- return { kind, action: action_, proposal };
449
+ return { kind, action: action_, proposal, proposedByMe: false }; // turn path = our own agreement_respond ran — we are the responder, by definition not the proposer
435
450
  }
436
451
  case 'tool-result':
437
452
  return { kind, tool, result };
@@ -471,7 +486,12 @@ export function applyOutcomeToCtx(ctx: Ctx, outcome: Outcome): void {
471
486
  const t = outcome.task || ({} as TaskRef);
472
487
  const tid = t.taskId;
473
488
  const agId = t.agreementId || t.agreement?.agreementId;
474
- if (tid && !ctx.delegatedTaskIds.includes(tid)) ctx.delegatedTaskIds.push(tid);
489
+ if (tid && !(tid in ctx.pendingSubtasks)) {
490
+ ctx.pendingSubtasks[tid] = {
491
+ agentId: t.assigneeId ?? t.executorId ?? t.agreement?.parties?.provider ?? null,
492
+ spawnedAt: Date.now(),
493
+ };
494
+ }
475
495
  if (agId && !ctx.delegatedAgreementIds.includes(agId)) ctx.delegatedAgreementIds.push(agId);
476
496
  return;
477
497
  }
@@ -500,7 +520,31 @@ export function applyOutcomeToCtx(ctx: Ctx, outcome: Outcome): void {
500
520
  const t = outcome.task;
501
521
  const taskId = t?.taskId;
502
522
  const agId = t?.agreementId || t?.agreement?.agreementId;
503
- if (taskId) ctx.delegatedTaskIds = ctx.delegatedTaskIds.filter(id => id !== taskId);
523
+ if (taskId) {
524
+ const pending = ctx.pendingSubtasks[taskId];
525
+ delete ctx.pendingSubtasks[taskId];
526
+ const payload = t?.result ?? null;
527
+ const existing = ctx.collectedResults.find(r => r.taskId === taskId);
528
+ if (!existing) {
529
+ ctx.collectedResults.push({
530
+ taskId,
531
+ agentId: pending?.agentId ?? t?.assigneeId ?? t?.executorId ?? t?.agreement?.parties?.provider ?? null,
532
+ status: outcome.status,
533
+ result: payload,
534
+ });
535
+ } else if (existing.result == null && payload != null) {
536
+ // A bare resource_changed(subtask_completed) skeleton ({taskId, state})
537
+ // can arrive BEFORE the full task_result for the same completion.
538
+ // Upgrade the placeholder in place instead of dropping the real payload.
539
+ // The skeleton often lacks executor identity, so also fill agentId
540
+ // from the fuller frame when the placeholder recorded none.
541
+ existing.status = outcome.status;
542
+ existing.result = payload;
543
+ if (existing.agentId == null) {
544
+ existing.agentId = t?.assigneeId ?? t?.executorId ?? t?.agreement?.parties?.provider ?? null;
545
+ }
546
+ }
547
+ }
504
548
  if (agId) ctx.delegatedAgreementIds = ctx.delegatedAgreementIds.filter(id => id !== agId);
505
549
  return;
506
550
  }
@@ -508,7 +552,8 @@ export function applyOutcomeToCtx(ctx: Ctx, outcome: Outcome): void {
508
552
  ctx.activeAgreementId = null;
509
553
  ctx.activeTaskId = null;
510
554
  ctx.pendingProposalAgreementId = null;
511
- ctx.delegatedTaskIds = [];
555
+ ctx.pendingSubtasks = {};
556
+ ctx.collectedResults = [];
512
557
  ctx.delegatedAgreementIds = [];
513
558
  return;
514
559
  }
@@ -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
+ }