@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.
- package/package.json +6 -3
- package/src/AgentHost.ts +347 -28
- package/src/adapters/AnthropicAdapter.ts +238 -0
- package/src/adapters/McpAdapter.ts +391 -15
- package/src/adapters/OpenAIAdapter.ts +20 -8
- package/src/adapters/index.ts +1 -0
- package/src/agent/Agent.ts +13 -2
- package/src/agent/ClaudeSdkAgent.ts +401 -0
- package/src/agent/claudeCognition.ts +139 -0
- package/src/agent/claudeSdkProtocol.ts +27 -0
- package/src/agent/claudeSdkTools.ts +81 -0
- package/src/agent/hostedAgentConnection.ts +41 -0
- package/src/agent/toolManagerToSdkMcp.ts +136 -0
- package/src/defineAgent.ts +22 -7
- package/src/index.ts +14 -5
- package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
- package/src/runtime/AgentMachine.ts +143 -23
- package/src/runtime/PromptBuilder.ts +62 -6
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/buildOutcome.ts +51 -6
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/kickoff.ts +117 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +236 -62
- package/src/runtime/validateWorkflow.ts +55 -15
- package/src/server/ConnectionPool.ts +56 -5
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/StateTimers.ts +85 -0
- package/src/server/ZiggsEffectHandler.ts +198 -180
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +15 -3
- package/src/server/tools/tier1/protocolRunner.ts +221 -3
- package/src/server/tools/tier1/protocolTools.ts +19 -4
- 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: '
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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 && !
|
|
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)
|
|
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.
|
|
555
|
+
ctx.pendingSubtasks = {};
|
|
556
|
+
ctx.collectedResults = [];
|
|
512
557
|
ctx.delegatedAgreementIds = [];
|
|
513
558
|
return;
|
|
514
559
|
}
|
package/src/runtime/defaults.ts
CHANGED
|
@@ -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
|
+
}
|