@ziggs-ai/agent-sdk 0.1.9 → 0.1.11

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.
@@ -40,6 +40,8 @@ export interface TickOutput {
40
40
  state: string;
41
41
  /** Whether the machine settled at a parked state. */
42
42
  parked: boolean;
43
+ /** True when a state-timeout outcome matched no transition this tick — the host must NOT re-arm. */
44
+ stateTimeoutUnhandled: boolean;
43
45
  /** Accumulated LLM call count and token usage for this tick. */
44
46
  metrics: { llmCalls: number; tokens: { total: number; input: number; output: number } };
45
47
  }
@@ -95,6 +97,7 @@ export class Agent {
95
97
  ctx: machine.ctx,
96
98
  state: machine.state,
97
99
  parked: machine.isParked,
100
+ stateTimeoutUnhandled: machine.stateTimeoutUnhandled,
98
101
  metrics: { llmCalls: machine.llmCalls, tokens: machine.tokens },
99
102
  };
100
103
  }
@@ -1,12 +1,16 @@
1
1
  import type { AgentConfig, DefineAgentInput, Workflow } from './types.js';
2
2
  import { validateWorkflow } from './runtime/validateWorkflow.js';
3
+ import { injectKickoffDefault } from './runtime/kickoff.js';
3
4
  import { runtimeLog } from './shared/runtimeLog.js';
4
5
 
5
6
  /**
6
7
  * Validates the workflow synchronously, resolves merged tools and model, and
7
- * returns a frozen AgentConfig. Does NOT mutate the input — `wait` action and
8
- * `activeWait` transitions are the workflow author's responsibility (visible
9
- * spread of `thinkingDefaults`).
8
+ * returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
9
+ * the author's visible spread of `thinkingDefaults`. ONE default is injected (as
10
+ * new objects): the agreement-kickoff state `kickingOff` plus a last-position
11
+ * transition into it, appended to every state that does not already handle a
12
+ * proposer-side proposal-resolved/approve outcome (runtime/kickoff.ts). The
13
+ * state name 'kickingOff' is reserved; defining it throws.
10
14
  *
11
15
  * Validation throws WorkflowValidationError on hard problems and logs warnings
12
16
  * on soft ones (e.g. missing wait fallthrough).
@@ -17,12 +21,14 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
17
21
  if (!input?.states) throw new Error('[defineAgent] states is required');
18
22
  if (!input?.initial) throw new Error('[defineAgent] initial state is required');
19
23
 
20
- const workflow: Workflow = {
24
+ // Agreement-kickoff default (see runtime/kickoff.ts): injected BEFORE
25
+ // validation so the injected state/transitions are validated too.
26
+ const workflow: Workflow = injectKickoffDefault({
21
27
  id: input.agentId,
22
28
  description: input.description,
23
29
  initial: input.initial,
24
30
  states: input.states,
25
- };
31
+ });
26
32
 
27
33
  const { warnings } = validateWorkflow(workflow);
28
34
  for (const w of warnings) runtimeLog.warn('defineAgent', w);
@@ -34,6 +40,7 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
34
40
 
35
41
  const model =
36
42
  input.services?.llm?.model ||
43
+ process.env.LLM_MODEL ||
37
44
  process.env.OPENAI_MODEL ||
38
45
  'gpt-5.4';
39
46
 
@@ -46,6 +53,7 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
46
53
 
47
54
  const config: AgentConfig = {
48
55
  openaiKey: process.env.OPENAI_API_KEY,
56
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
49
57
  ...(operatorKey ? { operatorKey } : {}),
50
58
  agentId,
51
59
  description,
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export { defineAgent } from './defineAgent.js';
12
- export { createMcpAgent } from './adapters/McpAdapter.js';
12
+ export { createMcpAgent, resolveZiggsGatewayTarget, wrapGatewayFetch, ZiggsGatewayError } from './adapters/McpAdapter.js';
13
13
  export type { CreateMcpAgentOptions, McpAgentHandle, McpTransportConfig } from './adapters/McpAdapter.js';
14
14
  export { AgentHost, createAgent, createAgentPool } from './AgentHost.js';
15
15
  export { Agent } from './agent/Agent.js';
@@ -18,6 +18,8 @@ export type { SessionResolver, AgentHostOptions } from './AgentHost.js';
18
18
  export { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
19
19
  export type { ZiggsEffectDeps } from './server/ZiggsEffectHandler.js';
20
20
  export { EventQueue } from './server/EventQueue.js';
21
+ export { StateTimers } from './server/StateTimers.js';
22
+ export type { StateTimeoutEvent } from './server/StateTimers.js';
21
23
  export { OutboxBuffer } from './server/OutboxBuffer.js';
22
24
  export { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
23
25
 
@@ -26,6 +28,7 @@ export { AgentMachine, type TransitionEvent } from './runtime/AgentMachine.js';
26
28
  export { runTurn } from './runtime/runTurn.js';
27
29
  export { PromptBuilder } from './runtime/PromptBuilder.js';
28
30
  export { thinkingDefaults, defineThinkingState, DEFAULT_WAIT_PROMPT } from './runtime/defaults.js';
31
+ export { KICKOFF_STATE, injectKickoffDefault, buildKickoffState } from './runtime/kickoff.js';
29
32
  export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
30
33
  export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
31
34
 
@@ -17,6 +17,13 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
17
17
  "shopper-agent": "D",
18
18
  "hire-agent": "B",
19
19
  "ziggs-docs-agent": "B",
20
+ "relay-coordinator": "D",
21
+ "relay-intake-worker": "D",
22
+ "relay-work-worker": "D",
23
+ "relay-deliver-worker": "D",
24
+ "retry-coordinator": "D",
25
+ "retry-fail-worker": "D",
26
+ "retry-ok-worker": "D",
20
27
  "weather-forecaster": "A",
21
28
  "climate-data-analyst": "A",
22
29
  "air-quality-monitor": "A",
@@ -25,6 +32,8 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
25
32
  "uv-index-advisor": "A",
26
33
  "pollen-tracker": "A",
27
34
  "marine-weather-reporter": "A",
35
+ "us-alerts-monitor": "A",
36
+ "daylight-tracker": "A",
28
37
  "stock-market-analyst": "A",
29
38
  "crypto-tracker": "A",
30
39
  "forex-monitor": "A",
@@ -40,12 +49,16 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
40
49
  "world-news-briefer": "A",
41
50
  "business-news-analyst": "A",
42
51
  "reddit-trend-spotter": "A",
52
+ "tech-news-digest": "A",
53
+ "news-searcher": "A",
54
+ "subreddit-pulse": "A",
43
55
  "science-news-reporter": "A",
44
56
  "sports-score-tracker": "A",
45
57
  "breaking-news-alert": "A",
46
58
  "wikipedia-researcher": "A",
47
59
  "book-finder": "A",
48
60
  "dictionary-thesaurus": "A",
61
+ "wikidata-explorer": "A",
49
62
  "trivia-master": "A",
50
63
  "poetry-curator": "A",
51
64
  "quote-finder": "A",
@@ -75,6 +88,8 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
75
88
  "distance-calculator": "A",
76
89
  "place-finder": "A",
77
90
  "ip-geolocator": "A",
91
+ "postal-code-resolver": "A",
92
+ "brewery-finder": "A",
78
93
  "astronomy-photo-curator": "A",
79
94
  "mars-rover-explorer": "A",
80
95
  "neo-tracker": "A",
@@ -83,9 +98,16 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
83
98
  "exoplanet-explorer": "A",
84
99
  "drug-interaction-checker": "A",
85
100
  "solar-system-explorer": "A",
101
+ "species-lookup": "A",
102
+ "census-analyst": "A",
103
+ "river-monitor": "A",
104
+ "fbi-wanted-monitor": "A",
86
105
  "github-repo-analyst": "A",
87
106
  "github-issue-tracker": "A",
88
107
  "github-pr-reviewer": "A",
108
+ "package-inspector": "A",
109
+ "service-status-checker": "A",
110
+ "password-safety-checker": "A",
89
111
  "ip-analyst": "A",
90
112
  "qr-code-generator": "A",
91
113
  "http-debugger": "A",
@@ -117,6 +139,9 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
117
139
  "uuid-generator": "A",
118
140
  "hash-calculator": "A",
119
141
  "base64-encoder": "A",
142
+ "card-dealer": "A",
143
+ "world-clock": "A",
144
+ "link-unfurler": "A",
120
145
  "code-reviewer": "B",
121
146
  "bug-finder": "B",
122
147
  "refactoring-advisor": "B",
@@ -141,6 +166,30 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
141
166
  "stack-trace-analyzer": "B",
142
167
  "commit-message-writer": "B",
143
168
  "schema-designer": "B",
169
+ "name-brainstormer": "B",
170
+ "term-explainer": "B",
171
+ "pros-cons-lister": "B",
172
+ "decision-matrix-builder": "B",
173
+ "checklist-generator": "B",
174
+ "config-converter": "B",
175
+ "csv-to-table": "B",
176
+ "markup-converter": "B",
177
+ "case-converter": "B",
178
+ "color-converter": "B",
179
+ "timestamp-humanizer": "B",
180
+ "text-summarizer": "B",
181
+ "notes-cleaner": "B",
182
+ "audience-rewriter": "B",
183
+ "text-reformatter": "B",
184
+ "json-fixer": "B",
185
+ "sql-explainer": "B",
186
+ "regex-writer": "B",
187
+ "cron-explainer": "B",
188
+ "curl-converter": "B",
189
+ "changelog-writer": "B",
190
+ "stacktrace-explainer": "B",
191
+ "diff-explainer": "B",
192
+ "scaffold-generator": "B",
144
193
  "copywriter": "B",
145
194
  "technical-writer": "B",
146
195
  "blog-post-drafter": "B",
@@ -270,6 +319,9 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
270
319
  "feature-prioritizer": "C",
271
320
  "threat-model-committee": "C",
272
321
  "negotiation-simulator": "C",
322
+ "trip-desk": "C",
323
+ "tech-radar-brief": "C",
324
+ "market-open-brief": "C",
273
325
  "aristotle": "B",
274
326
  "plato": "B",
275
327
  "confucius": "B",
@@ -720,4 +772,4 @@ export const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier> = {
720
772
  "death-studies-scholar": "B"
721
773
  } as Record<string, FleetPricingTier>;
722
774
 
723
- export const FLEET_TIER_MAP_SIZE = 713;
775
+ export const FLEET_TIER_MAP_SIZE = 765;
@@ -16,7 +16,8 @@ export type TransitionEvent =
16
16
  | { kind: 'transition'; fromState: string; toState: string; outcomeKind: string; depth: number }
17
17
  | { kind: 'no-match'; state: string; outcomeKind: string; depth: number }
18
18
  | { kind: 'depth-guard'; state: string; depth: number }
19
- | { kind: 'error-escape'; fromState: string; toState: string };
19
+ | { kind: 'error-escape'; fromState: string; toState: string }
20
+ | { kind: 'timeout-unhandled'; state: string; waitedMs: number };
20
21
 
21
22
  interface MachineInput {
22
23
  identity: Identity;
@@ -78,10 +79,12 @@ export class AgentMachine {
78
79
  _llmCalls = 0;
79
80
  _tokens = { total: 0, input: 0, output: 0 };
80
81
  _lastUserSenderId: string | null = null;
82
+ _stateTimeoutUnhandled = false;
81
83
  _transitionTap: ((event: TransitionEvent) => void) | undefined;
82
84
 
83
85
  get llmCalls(): number { return this._llmCalls; }
84
86
  get tokens(): { total: number; input: number; output: number } { return this._tokens; }
87
+ get stateTimeoutUnhandled(): boolean { return this._stateTimeoutUnhandled; }
85
88
 
86
89
  constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap }: MachineOptions) {
87
90
  this.workflow = workflow;
@@ -93,14 +96,23 @@ export class AgentMachine {
93
96
  this._transitionTap = transitionTap;
94
97
  this.state = input.state || workflow.initial;
95
98
  this.ctx = input.ctx
96
- ? { ...input.ctx, identity: input.identity }
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
+ }
97
108
  : {
98
109
  identity: input.identity,
99
110
  activeAgreementId: null,
100
111
  activeTaskId: null,
101
112
  pendingProposalAgreementId: null,
102
113
  delegatedAgreementIds: [],
103
- delegatedTaskIds: [],
114
+ pendingSubtasks: {},
115
+ collectedResults: [],
104
116
  toolResults: [],
105
117
  lastOutcome: { kind: 'enter' },
106
118
  };
@@ -172,13 +184,18 @@ export class AgentMachine {
172
184
  // message, which triggers another resource_changed, and so on.
173
185
  if (ev && outcome.kind === 'enter') return;
174
186
 
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.
187
+ // Proposer detection must read pendingProposalAgreementId BEFORE
188
+ // applyOutcomeToCtx clears it on resolution (buildOutcome.ts). Wire frames
189
+ // carry no trustworthy proposer field — agreement_lifecycle drops `parties`
190
+ // in normalizeIncomingEvent, and parties.creator is a principal id, not an
191
+ // agentId (backend ZIG-272) — so the SDK's own "I made this proposal"
192
+ // memory is the signal. Side effect we rely on: the first resolution clears
193
+ // the pending id, so duplicate approval frames compute proposedByMe=false
194
+ // and can never double-kickoff.
195
+ if (outcome.kind === 'proposal-resolved') {
196
+ outcome.proposedByMe =
197
+ this.ctx.pendingProposalAgreementId !== null &&
198
+ outcome.proposal.agreementId === this.ctx.pendingProposalAgreementId;
182
199
  }
183
200
 
184
201
  applyOutcomeToCtx(this.ctx, outcome);
@@ -199,6 +216,24 @@ export class AgentMachine {
199
216
  this._notify();
200
217
  }
201
218
 
219
+ // A state-timeout only enters at depth 0 (host-armed frame via _handleFrame;
220
+ // turns cannot produce it — validateWorkflow rejects produces:'state-timeout').
221
+ // No matching transition = the author declared timeoutMs but no route: park
222
+ // loudly and tell the host not to re-arm (re-arming would error every
223
+ // timeoutMs forever). Next real event still recovers the lane. A matched
224
+ // transition (target !== null) — including an explicit self-loop — is
225
+ // "handled" and re-arms as a heartbeat.
226
+ if (outcome.kind === 'state-timeout' && depth === 0 && !target) {
227
+ this._stateTimeoutUnhandled = true;
228
+ runtimeLog.error(
229
+ 'AgentMachine',
230
+ `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.`,
231
+ );
232
+ this._transitionTap?.({ kind: 'timeout-unhandled', state: this.state, waitedMs: outcome.waitedMs });
233
+ this._notify();
234
+ return;
235
+ }
236
+
202
237
  const sd = this.workflow.states[this.state];
203
238
  if (!sd) return;
204
239
  if (sd.kind === 'parked') {
@@ -217,7 +252,7 @@ export class AgentMachine {
217
252
  fromState === this.state &&
218
253
  !target
219
254
  ) {
220
- if (this._escapeToInitialParked(outcome, fromState)) return;
255
+ if (await this._escapeToInitialParked(outcome, fromState)) return;
221
256
  runtimeLog.warn(
222
257
  'AgentMachine',
223
258
  `state=${fromState} outcome.kind=${(outcome as Record<string, unknown>).kind} matched no transition; parking (depth=${depth})`,
@@ -299,7 +334,7 @@ export class AgentMachine {
299
334
  }
300
335
 
301
336
  /** Error with no author-defined escape: park at workflow.initial if it is parked. */
302
- _escapeToInitialParked(outcome: Outcome, fromState: string): boolean {
337
+ async _escapeToInitialParked(outcome: Outcome, fromState: string): Promise<boolean> {
303
338
  if (outcome.kind !== 'error') return false;
304
339
  const initial = this.workflow.initial;
305
340
  const initialSd = this.workflow.states[initial];
@@ -312,6 +347,9 @@ export class AgentMachine {
312
347
  );
313
348
  this._transitionTap?.({ kind: 'error-escape', fromState, toState: initial });
314
349
  this.state = initial;
350
+ // The turn failed and no workflow transition handled it — a waiting human
351
+ // got nothing, so apologize before parking (same text as the other parks).
352
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
315
353
  this._notify();
316
354
  return true;
317
355
  }
@@ -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';
@@ -151,6 +151,11 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
151
151
  lines.push('');
152
152
  lines.push(this._renderAgreements(agreements));
153
153
 
154
+ if (ctx?.collectedResults?.length) {
155
+ lines.push('');
156
+ lines.push(this._renderSubtaskResults(ctx.collectedResults));
157
+ }
158
+
154
159
  if (users.length > 0 || otherAgents.length > 0) {
155
160
  lines.push('');
156
161
  lines.push(this._renderRecipients(users, otherAgents, agreements, agentId));
@@ -171,10 +176,24 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
171
176
  if (ctx.activeTaskId) out.activeTaskId = ctx.activeTaskId;
172
177
  if (ctx.pendingProposalAgreementId) out.pendingProposalAgreementId = ctx.pendingProposalAgreementId;
173
178
  if (ctx.delegatedAgreementIds?.length) out.delegatedAgreementIds = ctx.delegatedAgreementIds;
179
+ if (Object.keys(ctx.pendingSubtasks ?? {}).length) out.pendingSubtasks = ctx.pendingSubtasks;
174
180
  if (ctx.lastOutcome) out.lastOutcomeKind = ctx.lastOutcome.kind;
175
181
  return `<machine_context>\n${JSON.stringify(out, null, 2)}\n</machine_context>`;
176
182
  }
177
183
 
184
+ _renderSubtaskResults(results: CollectedSubtaskResult[]): string {
185
+ const lines: string[] = ['<subtask_results>'];
186
+ 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.`);
187
+ for (const r of results) {
188
+ const payload = r.result == null ? null : typeof r.result === 'string' ? r.result : JSON.stringify(r.result);
189
+ const display = payload && payload.length > 2000 ? payload.slice(0, 2000) + `… [truncated of ${payload.length} chars]` : payload;
190
+ lines.push(`- taskId=${r.taskId} agent=${r.agentId ?? 'unknown'} status=${r.status}`);
191
+ lines.push(` result: ${display ?? '(none)'}`);
192
+ }
193
+ lines.push('</subtask_results>');
194
+ return lines.join('\n');
195
+ }
196
+
178
197
  _renderEvent(event: RawEvent | AnyObj | null, senderMeta: SenderMeta): string {
179
198
  if (!event) return '<event type="none">No incoming event.</event>';
180
199
 
@@ -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
  }
@@ -0,0 +1,117 @@
1
+ import type { Outcome, State, ThinkingState, Transition, Workflow } from '../types.js';
2
+ import { thinkingDefaults } from './defaults.js';
3
+ import { PROBE_CTX } from './validateWorkflow.js';
4
+
5
+ /** Reserved state name for the injected agreement-kickoff default. */
6
+ export const KICKOFF_STATE = 'kickingOff';
7
+
8
+ // Synthetic outcome used to ask "does this state already handle a proposer-side
9
+ // approval?" — the same probe pattern validateWorkflow uses for the wait/error
10
+ // fallthrough checks.
11
+ const PROBE_APPROVE: Outcome = {
12
+ kind: 'proposal-resolved',
13
+ action: 'approve',
14
+ proposal: { agreementId: '__kickoff_probe__' },
15
+ proposedByMe: true,
16
+ };
17
+
18
+ const KICKOFF_TRANSITION: Transition = {
19
+ to: KICKOFF_STATE,
20
+ when: (o, ctx) =>
21
+ o.kind === 'proposal-resolved' &&
22
+ o.action === 'approve' &&
23
+ o.proposedByMe === true &&
24
+ // "first task" guard — both branches cover one exact scenario: a task under
25
+ // THIS agreement already exists because a racing task_result frame was
26
+ // applied to ctx before this approval frame.
27
+ // (1) we already delegated under it:
28
+ !ctx.delegatedAgreementIds.includes(o.proposal.agreementId) &&
29
+ // (2) we already hold an assigned task bound to it (task-assigned sets
30
+ // activeTaskId + activeAgreementId together in applyOutcomeToCtx):
31
+ !(ctx.activeTaskId !== null && ctx.activeAgreementId === o.proposal.agreementId),
32
+ };
33
+
34
+ // A state "handles" proposer-approval if some transition would fire for it.
35
+ // An unconditional (when-less) transition counts: it catches every outcome,
36
+ // so the kickoff transition — appended LAST — would be unreachable dead code
37
+ // there anyway. Consequence to know: a state whose only route for a
38
+ // proposal-resolved/approve is an unconditional fallthrough to an UNRELATED
39
+ // state will NOT auto-kickoff (the approval follows the fallthrough instead).
40
+ // Authors who want guaranteed kickoff in such a state must add an explicit
41
+ // `when: o => o.kind === 'proposal-resolved' && o.action === 'approve'`
42
+ // transition (which this probe then respects) — or route approval into a
43
+ // thinking state that spawns the task itself.
44
+ function stateHandlesProposerApproval(state: State): boolean {
45
+ return state.transitions.some(t => {
46
+ if (!t.when) return true;
47
+ try { return !!t.when(PROBE_APPROVE, PROBE_CTX); }
48
+ catch { return false; }
49
+ });
50
+ }
51
+
52
+ export function buildKickoffState(initial: string): ThinkingState {
53
+ const dflt = thinkingDefaults({ initial });
54
+ return {
55
+ kind: 'thinking',
56
+ prompt: {
57
+ role: 'The counterparty just approved an agreement you proposed. The platform never auto-starts work — as the proposer, you spawn the first task now.',
58
+ goal: 'Spawn exactly one task under the just-approved agreement so work begins.',
59
+ constraints:
60
+ 'The approved agreement id is in <event> and as activeAgreementId in <machine_context>; the agreement row is in <agreements>. Call task_spawn with that agreementId and a description of the concrete work the agreement covers. Include ALL specifics the counterparty provided in <history> (addresses, quantities, times, item names, deliverables) — the executor has no other way to get them. Plain natural language, no step numbering. Do not re-negotiate — the agreement is active. Do not send a chat message instead of spawning. If <agreements> or <history> show a task already running under this agreement, choose wait.',
61
+ },
62
+ actions: {
63
+ ...dflt.actions,
64
+ spawnFirstTask: {
65
+ prompt: {
66
+ when: 'Always — this state exists only to spawn the first task under the agreement whose proposal was just approved. Pick wait only if a task already exists under it.',
67
+ instruction: 'Spawn the first task under the approved agreement so work starts.',
68
+ format: '{"agreementId": "<the agreement whose proposal was just approved>", "description": "<concrete work + all counterparty specifics from history>"}',
69
+ },
70
+ tool: 'task_spawn',
71
+ // Function form so a failed task_spawn surfaces as an error outcome
72
+ // (→ dflt error transition + machine warn log) instead of a fake
73
+ // task-delegated with an empty task.
74
+ produces: (result) => {
75
+ const r = result as { taskId?: string; task?: { taskId?: string } } | undefined;
76
+ return r?.taskId || r?.task?.taskId ? 'task-delegated' : 'error';
77
+ },
78
+ terminatesLoop: true,
79
+ },
80
+ },
81
+ transitions: [
82
+ { to: initial, when: o => o.kind === 'task-delegated' },
83
+ ...dflt.transitions, // wait / message-sent / error → initial
84
+ ],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Agreement-kickoff default. For every state whose transitions do not already
90
+ * match a proposer-side approval (probe above), append a transition to the
91
+ * injected `kickingOff` state. Appended LAST so any user transition on the same
92
+ * outcome always wins (first match wins in AgentMachine._evaluateTransitions).
93
+ * Returns a NEW workflow — input states are not mutated. After kickoff the
94
+ * machine returns to `initial`; when this agent is itself the provider the
95
+ * backend then assigns the spawned task back to it as a normal task-assigned
96
+ * event, which the author's existing work loop handles.
97
+ */
98
+ export function injectKickoffDefault(workflow: Workflow): Workflow {
99
+ if (workflow.states[KICKOFF_STATE]) {
100
+ throw new Error(
101
+ `[defineAgent] state name "${KICKOFF_STATE}" is reserved for the injected agreement-kickoff default — rename the state`,
102
+ );
103
+ }
104
+ const states: Record<string, State> = {};
105
+ let injected = false;
106
+ for (const [name, state] of Object.entries(workflow.states)) {
107
+ if (stateHandlesProposerApproval(state)) {
108
+ states[name] = state;
109
+ continue;
110
+ }
111
+ states[name] = { ...state, transitions: [...state.transitions, KICKOFF_TRANSITION] } as State;
112
+ injected = true;
113
+ }
114
+ if (!injected) return workflow; // every state handles approval — nothing to add, no dead state
115
+ states[KICKOFF_STATE] = buildKickoffState(workflow.initial);
116
+ return { ...workflow, states };
117
+ }