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