@ziggs-ai/agent-sdk 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +6 -3
  2. package/src/AgentHost.ts +347 -28
  3. package/src/adapters/AnthropicAdapter.ts +238 -0
  4. package/src/adapters/McpAdapter.ts +391 -15
  5. package/src/adapters/OpenAIAdapter.ts +20 -8
  6. package/src/adapters/index.ts +1 -0
  7. package/src/agent/Agent.ts +13 -2
  8. package/src/agent/ClaudeSdkAgent.ts +401 -0
  9. package/src/agent/claudeCognition.ts +139 -0
  10. package/src/agent/claudeSdkProtocol.ts +27 -0
  11. package/src/agent/claudeSdkTools.ts +81 -0
  12. package/src/agent/hostedAgentConnection.ts +41 -0
  13. package/src/agent/toolManagerToSdkMcp.ts +136 -0
  14. package/src/defineAgent.ts +22 -7
  15. package/src/index.ts +14 -5
  16. package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
  17. package/src/runtime/AgentMachine.ts +143 -23
  18. package/src/runtime/PromptBuilder.ts +62 -6
  19. package/src/runtime/bootstrapCtx.ts +39 -0
  20. package/src/runtime/buildOutcome.ts +51 -6
  21. package/src/runtime/defaults.ts +12 -1
  22. package/src/runtime/deriveRespondsToProposals.ts +35 -0
  23. package/src/runtime/kickoff.ts +117 -0
  24. package/src/runtime/runMechanical.ts +106 -0
  25. package/src/runtime/runTurn.ts +236 -62
  26. package/src/runtime/validateWorkflow.ts +55 -15
  27. package/src/server/ConnectionPool.ts +56 -5
  28. package/src/server/InboxCatchUp.ts +37 -0
  29. package/src/server/StateTimers.ts +85 -0
  30. package/src/server/ZiggsEffectHandler.ts +198 -180
  31. package/src/server/agreements/AgreementService.ts +3 -3
  32. package/src/server/tasks/TaskService.ts +15 -3
  33. package/src/server/tools/tier1/protocolRunner.ts +221 -3
  34. package/src/server/tools/tier1/protocolTools.ts +19 -4
  35. package/src/types.ts +105 -29
@@ -3,6 +3,7 @@ import {
3
3
  ContextDiscoveryClient,
4
4
  InboxClient,
5
5
  getChatsForAgreement,
6
+ getAgreementStatus,
6
7
  type InboxScopeEntry,
7
8
  type InboxAck,
8
9
  type Creds,
@@ -40,6 +41,22 @@ function warn(label: string, msg: string): void {
40
41
  runtimeLog.warn(label, msg);
41
42
  }
42
43
 
44
+ const TERMINAL_AGREEMENT_STATUSES = new Set(['fulfilled', 'cancelled', 'refunded']);
45
+
46
+ /** ZIG-832 P4: is this agreement finished (so its backlog must not be re-run)? */
47
+ async function isTerminalAgreement(agreementId: string, creds: Creds): Promise<boolean> {
48
+ try {
49
+ const res = (await getAgreementStatus(agreementId, creds)) as
50
+ | { status?: string; agreement?: { status?: string } }
51
+ | undefined;
52
+ const status = res?.status ?? res?.agreement?.status;
53
+ return status != null && TERMINAL_AGREEMENT_STATUSES.has(status);
54
+ } catch {
55
+ // On lookup failure, don't skip — fall through to normal delivery.
56
+ return false;
57
+ }
58
+ }
59
+
43
60
  /** Map Mongo message row → wire metadata for normalizeIncomingEvent. */
44
61
  function rowToMetadata(row: WireRow, chatId: string): Record<string, unknown> {
45
62
  const senderRaw = row.sender as { agentId?: string; id?: string; type?: string } | undefined;
@@ -182,6 +199,26 @@ export async function runInboxCatchUp(deps: InboxCatchUpDeps): Promise<InboxCatc
182
199
  }
183
200
 
184
201
  for (const scope of envelope.scopes) {
202
+ // ZIG-832 P4: a re-host runs catch-up from the server watermark and would
203
+ // re-deliver an already-finished agreement's backlog, re-running a completed
204
+ // pipeline and burning tokens. If this is an agreement scope that has reached
205
+ // a terminal status, ack it (so it drops out of future catch-ups) and skip
206
+ // re-delivery. Only agreement scopes — chat scopes may carry genuinely new
207
+ // conversation, so those are never skipped on status. (The deeper fix, acking
208
+ // the watermark on the live-push path so nothing re-delivers, is tracked
209
+ // separately.)
210
+ if (
211
+ scope.scope.kind === 'agreement' &&
212
+ (scope.newMessages > 0 || scope.newArtifacts > 0) &&
213
+ (await isTerminalAgreement(scope.scope.id, deps.creds))
214
+ ) {
215
+ if (scope.latestAt) {
216
+ toAck.push({ kind: scope.scope.kind, id: scope.scope.id, upTo: scope.latestAt });
217
+ }
218
+ log(label, `scope agreement:${scope.scope.id} terminal — acked, not re-delivered`);
219
+ continue;
220
+ }
221
+
185
222
  const chatIds = await chatIdsForScope(scope, deps.creds, deps.baseUrl, discovery);
186
223
  if (!chatIds.length && (scope.newMessages > 0 || scope.newArtifacts > 0)) {
187
224
  warn(
@@ -0,0 +1,85 @@
1
+ import { runtimeLog } from '../shared/runtimeLog.js';
2
+
3
+ export interface StateTimeoutEvent {
4
+ type: 'state-timeout';
5
+ state: string;
6
+ waitedMs: number;
7
+ epoch: number;
8
+ }
9
+
10
+ interface ArmedTimer {
11
+ epoch: number;
12
+ state: string;
13
+ timeoutMs: number;
14
+ armedAt: number;
15
+ handle: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ /**
19
+ * Per-lane liveness timers for parked states declaring timeoutMs. Owned by
20
+ * AgentHost. A firing timer enqueues a state-timeout frame on the SAME lane
21
+ * (caller-provided enqueue → EventQueue), so it serializes behind any real
22
+ * event and can never race one. Epochs make stale frames identifiable: a
23
+ * queued timeout frame whose epoch is not the lane's currently-armed epoch was
24
+ * superseded (a tick ran after it fired) and must be dropped.
25
+ *
26
+ * In-memory only: process restart loses all timers; each lane re-arms on its
27
+ * next real event (sessionStore persists { ctx, state } but not timers).
28
+ */
29
+ export class StateTimers {
30
+ private _timers = new Map<string, ArmedTimer>();
31
+ private _epoch = 0;
32
+ private _enqueue: (event: StateTimeoutEvent, laneKey: string) => void;
33
+
34
+ constructor(enqueue: (event: StateTimeoutEvent, laneKey: string) => void) {
35
+ this._enqueue = enqueue;
36
+ }
37
+
38
+ /** Re-arm is idempotent: any previously armed timer for the lane is cancelled first. */
39
+ arm(laneKey: string, state: string, timeoutMs: number): void {
40
+ this.clear(laneKey);
41
+ const epoch = ++this._epoch;
42
+ const handle = setTimeout(() => {
43
+ runtimeLog.debug('StateTimers', `fired laneKey=${laneKey} state=${state} waitedMs=${timeoutMs} epoch=${epoch}`);
44
+ this._enqueue({ type: 'state-timeout', state, waitedMs: timeoutMs, epoch }, laneKey);
45
+ }, timeoutMs);
46
+ (handle as unknown as { unref?: () => void }).unref?.();
47
+ this._timers.set(laneKey, { epoch, state, timeoutMs, armedAt: Date.now(), handle });
48
+ }
49
+
50
+ clear(laneKey: string): void {
51
+ const t = this._timers.get(laneKey);
52
+ if (!t) return;
53
+ clearTimeout(t.handle);
54
+ this._timers.delete(laneKey);
55
+ }
56
+
57
+ clearAll(): void {
58
+ for (const key of [...this._timers.keys()]) this.clear(key);
59
+ }
60
+
61
+ /**
62
+ * Drop stale state-timeout events from an incoming frame (single event or an
63
+ * EventQueue-coalesced { type: 'batch', events } frame). Returns null when
64
+ * nothing survives (pure stale-timeout frame — the lane's live timer, if any,
65
+ * must be left untouched by the caller in that case).
66
+ */
67
+ stripStale(event: unknown, laneKey: string): unknown | null {
68
+ const pending = this._timers.get(laneKey);
69
+ const fresh = (ev: unknown): boolean => {
70
+ const e = ev as { type?: string; epoch?: number };
71
+ if (e?.type !== 'state-timeout') return true;
72
+ const ok = pending !== undefined && e.epoch === pending.epoch;
73
+ if (!ok) runtimeLog.debug('StateTimers', `dropping stale state-timeout laneKey=${laneKey} epoch=${e.epoch} armed=${pending?.epoch ?? 'none'}`);
74
+ return ok;
75
+ };
76
+ const b = event as { type?: string; events?: unknown[] };
77
+ if (b?.type === 'batch' && Array.isArray(b.events)) {
78
+ const kept = b.events.filter(fresh);
79
+ if (kept.length === 0) return null;
80
+ if (kept.length === b.events.length) return event;
81
+ return kept.length === 1 ? kept[0] : { type: 'batch', events: kept };
82
+ }
83
+ return fresh(event) ? event : null;
84
+ }
85
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Ziggs-flavored EffectHandler.
3
3
  *
4
- * Translates the Agent's pure Effect requests into concrete calls against
5
- * Ziggs backend primitives (MessagesClient, ArtifactsClient, ScopeClient,
6
- * taskService, agreementService, messageSender) and the OpenAI adapter. This
7
- * is the only place where Agent intent meets backend reality.
4
+ * Translates the Agent's pure Effect requests into concrete backend calls:
5
+ * reads go through the grant-fenced ContextReadClient snapshot, writes through
6
+ * artifactsClient / taskService / agreementService / messageSender, and LLM
7
+ * turns through the adapter. This is the only place where Agent intent meets
8
+ * backend reality.
8
9
  *
9
10
  * Lives in agent-sdk for now alongside `Agent`; destined for a separate
10
11
  * `@ziggs-ai/agent-server` package once the SDK split is complete.
@@ -18,15 +19,14 @@ import { runtimeLog } from '../shared/runtimeLog.js';
18
19
  /** Minimal LLM adapter contract — OpenAIAdapter satisfies this. */
19
20
  interface LlmAdapter {
20
21
  chatMessages(messages: LlmMessage[], tools?: LlmToolSchema[], options?: Record<string, unknown>): Promise<LlmResponse>;
21
- chatMessagesStream?(messages: LlmMessage[], onChunk: (text: string) => void): Promise<string>;
22
+ chatMessagesStream?(messages: LlmMessage[], onChunk: (text: string) => void): Promise<{ text: string; usage?: LlmResponse['usage'] }>;
22
23
  }
23
24
  import type { TaskService } from './tasks/TaskService.js';
24
25
  import type { AgreementService } from './agreements/AgreementService.js';
25
26
  import type { ToolManager } from '../tools/ToolManager.js';
26
27
  import type {
27
- MessagesClient,
28
28
  ArtifactsClient,
29
- ScopeClient,
29
+ ContextReadClient,
30
30
  TelemetryClient,
31
31
  } from '@ziggs-ai/api-client';
32
32
  import {
@@ -37,10 +37,15 @@ import {
37
37
  } from './telemetryIngest.js';
38
38
 
39
39
  export interface ZiggsEffectDeps {
40
- llm: LlmAdapter;
41
- messagesClient: MessagesClient;
40
+ /**
41
+ * LLM adapter for `llm-call` / `stream-text`. Optional: the Claude brain
42
+ * drives its own model via `query()` and emits neither effect, so it wires
43
+ * no adapter. Those two cases throw if reached without one.
44
+ */
45
+ llm?: LlmAdapter;
42
46
  artifactsClient: ArtifactsClient;
43
- scopeClient: ScopeClient;
47
+ /** Grant-fenced aggregate read backbone — sources the read-context snapshot. */
48
+ contextReadClient: ContextReadClient;
44
49
  taskService: TaskService;
45
50
  agreementService: AgreementService;
46
51
  messageSender: (text: string, receiverId: string, sessionId: string, messageId?: string) => Promise<unknown>;
@@ -58,69 +63,47 @@ export interface ZiggsEffectDeps {
58
63
  const CONTEXT_CACHE_MAX = 256;
59
64
 
60
65
  /**
61
- * Compose a `ContextSnapshot` for a chatId by fanning out to scope + messages
62
- * primitives. Replaces the single `/context/read` aggregator with parallel
63
- * pulls. The shape stays the same so PromptBuilder / cognition code doesn't
64
- * change.
66
+ * Compose a `ContextSnapshot` for a chatId via the grant-fenced aggregate read
67
+ * (`GET /context/snapshot`, ZIG-775 follow-up). The backend assembles history +
68
+ * agreements + roster through the same `assertScopeAccess` + temporal fence as
69
+ * the per-type context reads, and annotates `*IsYou` / self server-side against
70
+ * the acting agent. This function only preserves the never-throw/degrade
71
+ * contract that the cognition code depends on — the shape is unchanged.
65
72
  */
66
- const annotateAgreement = (ag: Record<string, unknown>, agentId: string): Record<string, unknown> => {
67
- const parties = (ag?.parties ?? {}) as { creator?: string; provider?: string; payer?: string; proposedTo?: string };
68
- return {
69
- ...ag,
70
- creatorIsYou: parties.creator === agentId,
71
- providerIsYou: parties.provider === agentId,
72
- payerIsYou: parties.payer === agentId,
73
- proposedToIsYou: parties.proposedTo === agentId,
74
- };
75
- };
73
+ const emptySnapshot = (agentId: string, chatMissing = false): ContextSnapshot => ({
74
+ history: [],
75
+ agreements: [],
76
+ users: [],
77
+ agents: [{ agentId, role: 'self', isYou: true }],
78
+ latestSequence: null,
79
+ ...(chatMissing ? { chatMissing: true } : {}),
80
+ });
76
81
 
77
82
  async function buildSnapshot(
78
83
  deps: ZiggsEffectDeps,
79
84
  chatId: string,
80
85
  agentId: string,
81
- opts: { after?: string; limit?: number } = {},
86
+ opts: { limit?: number } = {},
82
87
  ): Promise<ContextSnapshot> {
83
- const [scope, msgs, chatAgreements] = await Promise.all([
84
- deps.scopeClient.get('chat', chatId).catch((err: unknown) => {
85
- runtimeLog.warn('ZiggsEffectHandler', `scope fetch failed: ${(err as Error)?.message ?? err}`);
86
- return null;
87
- }),
88
- deps.messagesClient
89
- .list(chatId, { after: opts.after, limit: opts.limit })
90
- .catch((err: unknown) => {
91
- runtimeLog.warn('ZiggsEffectHandler', `messages fetch failed: ${(err as Error)?.message ?? err}`);
92
- return { messages: [], latestSequence: null };
93
- }),
94
- deps.agreementService.getByChat(chatId).catch((err: unknown) => {
95
- runtimeLog.warn('ZiggsEffectHandler', `agreements fetch failed: ${(err as Error)?.message ?? err}`);
96
- return [] as unknown[];
97
- }),
98
- ]);
99
-
100
- const counterparties = (scope?.accessible?.counterparties ?? []) as Array<{
101
- id: string;
102
- role: string;
103
- principalType?: 'user' | 'agent';
104
- }>;
105
-
106
- const others = counterparties.filter((c) => c.id !== agentId);
107
- const users = others
108
- .filter((c) => c.principalType === 'user')
109
- .map((c) => ({ userId: c.id, role: c.role }));
110
- const agents = others
111
- .filter((c) => c.principalType !== 'user') // 'agent' or missing → treat as agent
112
- .map((c) => ({ agentId: c.id, role: c.role, isYou: false }));
113
- agents.push({ agentId, role: 'self', isYou: true });
114
-
115
- const agreements = (chatAgreements as Record<string, unknown>[]).map((ag) => annotateAgreement(ag, agentId));
116
-
117
- return {
118
- history: msgs.messages as Record<string, unknown>[],
119
- agreements,
120
- agents,
121
- users,
122
- latestSequence: msgs.latestSequence,
123
- };
88
+ try {
89
+ // Server returns 200 + chatMissing:true for a deleted chat, so its
90
+ // annotations (roster, *IsYou, self) are already applied — pass through.
91
+ const snap = await deps.contextReadClient.snapshot(chatId, {
92
+ maxMessages: opts.limit,
93
+ });
94
+ return snap as ContextSnapshot;
95
+ } catch (err: unknown) {
96
+ // Defensive: server normally signals a dead chat as 200 + chatMissing, but
97
+ // a raw 404 degrades the same way so message wakes still skip it.
98
+ if ((err as { status?: number })?.status === 404) {
99
+ return emptySnapshot(agentId, true);
100
+ }
101
+ runtimeLog.warn(
102
+ 'ZiggsEffectHandler',
103
+ `snapshot fetch failed: ${(err as Error)?.message ?? err}`,
104
+ );
105
+ return emptySnapshot(agentId);
106
+ }
124
107
  }
125
108
 
126
109
  const OP_ARTIFACT_MAX_CHARS = 900;
@@ -178,6 +161,126 @@ async function emitToolOperationArtifact(
178
161
  }
179
162
  }
180
163
 
164
+ /**
165
+ * The single tool-execution path for BOTH cognitions (ZIG-775).
166
+ *
167
+ * Executing a tool is more than calling its handler: it emits the operation
168
+ * timeline artifacts (started / completed / error) and tool telemetry, and
169
+ * resolves the invoking user + roster. The FSM reaches this via
170
+ * `eff({ kind: 'tool-call' })` inside `runTurn`; the Claude Agent SDK bridge
171
+ * (`toolManagerToSdkMcp`) emits the SAME effect — so a hosted Claude-brain
172
+ * agent's tool activity is as observable as an FSM agent's (needed for
173
+ * hired-agent visibility, ZIG-535). A caller cannot run a tool and skip these
174
+ * side effects — that is the whole point of funneling both paths here.
175
+ *
176
+ * `eff` is threaded into the tool context so eff-backed tools
177
+ * (`ziggs_context_snapshot`, `ziggs_send_message`) resolve through this same path
178
+ * regardless of which cognition invoked them.
179
+ */
180
+ export async function executeToolWithSideEffects(
181
+ deps: Pick<
182
+ ZiggsEffectDeps,
183
+ | 'toolManager'
184
+ | 'artifactsClient'
185
+ | 'telemetryClient'
186
+ | 'telemetryIngest'
187
+ | 'operatorKey'
188
+ | 'agentId'
189
+ | 'taskService'
190
+ | 'agreementService'
191
+ | 'contextReadClient'
192
+ | 'messageSender'
193
+ >,
194
+ eff: EffectHandler,
195
+ effect: Extract<Effect, { kind: 'tool-call' }>,
196
+ roster: { agents: unknown[]; users: unknown[] },
197
+ ): Promise<ToolCallResult> {
198
+ const toolT0 = Date.now();
199
+ const args = (effect.args as Record<string, unknown>) || {};
200
+ const argsPreview = previewToolArgs(args);
201
+ await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
202
+ state: 'started',
203
+ tool: effect.name,
204
+ ...(argsPreview ? { args: argsPreview } : {}),
205
+ });
206
+ try {
207
+ const lastUser = (roster.users as Array<{ userId?: string; id?: string }>).find(
208
+ (u) => u.userId || u.id,
209
+ );
210
+ // Accumulate token usage from any nested LLM call the tool makes (llmTool),
211
+ // so it can be folded into the turn's authoritative token count (ZIG-813).
212
+ let nestedUsage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined;
213
+ const reportUsage = (u: LlmResponse['usage']): void => {
214
+ if (!u) return;
215
+ const p = u.prompt_tokens ?? u.promptTokens ?? 0;
216
+ const c = u.completion_tokens ?? u.completionTokens ?? 0;
217
+ const t = u.total_tokens ?? u.totalTokens ?? p + c;
218
+ nestedUsage = {
219
+ prompt_tokens: (nestedUsage?.prompt_tokens ?? 0) + p,
220
+ completion_tokens: (nestedUsage?.completion_tokens ?? 0) + c,
221
+ total_tokens: (nestedUsage?.total_tokens ?? 0) + t,
222
+ };
223
+ };
224
+ const result = await deps.toolManager.executeTool(effect.name, args, {
225
+ operatorKey: deps.operatorKey,
226
+ agentId: deps.agentId,
227
+ chatId: effect.sessionId,
228
+ taskService: deps.taskService,
229
+ agreementService: deps.agreementService,
230
+ contextReadClient: deps.contextReadClient,
231
+ sendMessage: deps.messageSender,
232
+ eff,
233
+ agents: roster.agents,
234
+ users: roster.users,
235
+ senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
236
+ senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
237
+ onBehalfOfAgreementId: effect.onBehalfOfAgreementId,
238
+ memory: effect.memory,
239
+ reportUsage,
240
+ turnScratch: {},
241
+ });
242
+ await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
243
+ state: 'completed',
244
+ tool: effect.name,
245
+ result: clipForOperationArtifact(result),
246
+ });
247
+ sendTelemetry(
248
+ deps.telemetryClient,
249
+ buildToolIngestPayload({
250
+ sessionId: effect.sessionId,
251
+ tool: effect.name,
252
+ durationMs: Date.now() - toolT0,
253
+ ok: true,
254
+ runId: effect.runId,
255
+ stateId: effect.stateId,
256
+ }),
257
+ isTelemetryIngestEnabled(deps.telemetryIngest),
258
+ );
259
+ return { ok: true, result, ...(nestedUsage ? { usage: nestedUsage } : {}) } satisfies ToolCallResult;
260
+ } catch (error: unknown) {
261
+ const errMsg = (error as Error)?.message ?? String(error);
262
+ runtimeLog.warn('ZiggsEffectHandler', `tool-call "${effect.name}" failed: ${errMsg}`);
263
+ await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
264
+ state: 'failed',
265
+ tool: effect.name,
266
+ error: errMsg,
267
+ });
268
+ sendTelemetry(
269
+ deps.telemetryClient,
270
+ buildToolIngestPayload({
271
+ sessionId: effect.sessionId,
272
+ tool: effect.name,
273
+ durationMs: Date.now() - toolT0,
274
+ ok: false,
275
+ runId: effect.runId,
276
+ stateId: effect.stateId,
277
+ }),
278
+ isTelemetryIngestEnabled(deps.telemetryIngest),
279
+ );
280
+ return { ok: false, error: errMsg } satisfies ToolCallResult;
281
+ }
282
+ }
283
+
181
284
  /**
182
285
  * Build a single shared EffectHandler. The handler is stateful — it caches
183
286
  * the latest ContextSnapshot per sessionId so tool-call effects can resolve
@@ -209,10 +312,10 @@ export function createZiggsEffectHandler(deps: ZiggsEffectDeps): EffectHandler {
209
312
  const dispatch = async (effect: Effect): Promise<unknown> => {
210
313
  switch (effect.kind) {
211
314
  case 'read-context': {
212
- // Composite: assembles ContextSnapshot from scope + messages.
213
- // Each agent runtime still gets the same shape, but the backend has
214
- // no `/context/read` — assembly happens here, where the agent's
215
- // identity is in scope.
315
+ // Grant-fenced aggregate read: the backend composes the ContextSnapshot
316
+ // (GET /context/snapshot) through the same access + temporal fence as
317
+ // the per-type reads. Every producer — ClaudeSdkAgent.tick, runTurn,
318
+ // and the on-demand ziggs_context_snapshot tool — routes through here.
216
319
  const snap = await buildSnapshot(deps, effect.sessionId, deps.agentId, {
217
320
  limit: effect.opts?.maxMessages,
218
321
  });
@@ -222,44 +325,9 @@ export function createZiggsEffectHandler(deps: ZiggsEffectDeps): EffectHandler {
222
325
  return snap;
223
326
  }
224
327
 
225
- case 'list-messages': {
226
- return deps.messagesClient.list(effect.chatId, {
227
- after: effect.after,
228
- limit: effect.limit,
229
- });
230
- }
231
-
232
- case 'list-artifacts': {
233
- return deps.artifactsClient.list(
234
- { chatId: effect.chatId, agreementId: effect.agreementId },
235
- { after: effect.after, limit: effect.limit },
236
- );
237
- }
238
-
239
- case 'list-task-history': {
240
- const url = new URL(
241
- `${process.env.ZIGGS_BACKEND_URL ?? 'http://localhost:3000'}/tasks/${effect.taskId}/history`,
242
- );
243
- if (effect.after) url.searchParams.set('after', effect.after);
244
- if (effect.limit != null) url.searchParams.set('limit', String(effect.limit));
245
- const res = await fetch(url.toString(), {
246
- headers: {
247
- 'content-type': 'application/json',
248
- Authorization: `Bearer ${deps.operatorKey}`,
249
- 'X-Agent-Id': deps.agentId,
250
- },
251
- });
252
- if (!res.ok) {
253
- throw new Error(`list-task-history ${res.status} ${res.statusText}`);
254
- }
255
- return res.json();
256
- }
257
-
258
- case 'get-scope': {
259
- return deps.scopeClient.get(effect.via.kind, effect.via.id);
260
- }
261
328
 
262
329
  case 'llm-call': {
330
+ if (!deps.llm) throw new Error('llm-call effect requires an LLM adapter — none configured for this cognition');
263
331
  const t0 = Date.now();
264
332
  const r: LlmResponse = await deps.llm.chatMessages(effect.messages, effect.tools);
265
333
  sendTelemetry(
@@ -276,94 +344,44 @@ export function createZiggsEffectHandler(deps: ZiggsEffectDeps): EffectHandler {
276
344
  }
277
345
 
278
346
  case 'stream-text': {
347
+ if (!deps.llm) throw new Error('stream-text effect requires an LLM adapter — none configured for this cognition');
279
348
  const { sessionId, receiverId, messageId, messages } = effect;
280
349
  let text = '';
350
+ let usage: LlmResponse['usage'];
281
351
  if (deps.llm.chatMessagesStream) {
282
- text = await deps.llm.chatMessagesStream(messages as LlmMessage[], (chunk) => {
283
- deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
352
+ const streamed = await deps.llm.chatMessagesStream(messages as LlmMessage[], (chunk) => {
353
+ // Live-stream display is best-effort. The transport's send rejects
354
+ // empty/whitespace-only content (WebSocketClient: content.trim() === ''),
355
+ // and a whitespace-only chunk (e.g. a leading newline) is truthy yet
356
+ // still throws — which, uncaught, aborts the whole stream and loses the
357
+ // entire reply (e.g. a composer's final synthesis). Swallow chunk-send
358
+ // failures; `text` accumulates independently and the final send-message
359
+ // (below, in runTurn) carries the full reply regardless.
360
+ try {
361
+ if (chunk && chunk.trim()) deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
362
+ } catch { /* ignore — streaming display only */ }
284
363
  });
364
+ text = streamed.text;
365
+ usage = streamed.usage;
285
366
  } else {
286
367
  const r = await deps.llm.chatMessages(messages as LlmMessage[]);
287
368
  text = r.content ?? '';
369
+ usage = r.usage;
288
370
  if (text) deps.chunkSender?.(sessionId, receiverId, text, messageId);
289
371
  }
290
- return { text };
372
+ return { text, usage };
291
373
  }
292
374
 
293
375
  case 'tool-call': {
294
- const toolT0 = Date.now();
376
+ // Roster comes from the cached snapshot (populated by read-context). Both
377
+ // cognitions land here — the FSM via runTurn's executeTool, the Claude
378
+ // Agent SDK via the toolManagerToSdkMcp bridge — so tool side effects are
379
+ // emitted exactly once, in one place (ZIG-775).
295
380
  const cached = cacheGet(effect.sessionId);
296
- const agents = (cached?.agents as unknown[]) || [];
297
- const users = (cached?.users as unknown[]) || [];
298
- const args = (effect.args as Record<string, unknown>) || {};
299
- const argsPreview = previewToolArgs(args);
300
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
301
- state: 'started',
302
- tool: effect.name,
303
- ...(argsPreview ? { args: argsPreview } : {}),
381
+ return executeToolWithSideEffects(deps, handler as EffectHandler, effect, {
382
+ agents: (cached?.agents as unknown[]) || [],
383
+ users: (cached?.users as unknown[]) || [],
304
384
  });
305
- try {
306
- const lastUser = (users as Array<{ userId?: string; id?: string }>).find(
307
- (u) => u.userId || u.id,
308
- );
309
- const result = await deps.toolManager.executeTool(
310
- effect.name,
311
- args,
312
- {
313
- operatorKey: deps.operatorKey,
314
- agentId: deps.agentId,
315
- chatId: effect.sessionId,
316
- taskService: deps.taskService,
317
- agreementService: deps.agreementService,
318
- sendMessage: deps.messageSender,
319
- agents,
320
- users,
321
- senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
322
- senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
323
- memory: effect.memory,
324
- turnScratch: {},
325
- },
326
- );
327
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
328
- state: 'completed',
329
- tool: effect.name,
330
- result: clipForOperationArtifact(result),
331
- });
332
- sendTelemetry(
333
- deps.telemetryClient,
334
- buildToolIngestPayload({
335
- sessionId: effect.sessionId,
336
- tool: effect.name,
337
- durationMs: Date.now() - toolT0,
338
- ok: true,
339
- runId: effect.runId,
340
- stateId: effect.stateId,
341
- }),
342
- isTelemetryIngestEnabled(deps.telemetryIngest),
343
- );
344
- return { ok: true, result } satisfies ToolCallResult;
345
- } catch (error: unknown) {
346
- const errMsg = (error as Error)?.message ?? String(error);
347
- runtimeLog.warn('ZiggsEffectHandler', `tool-call "${effect.name}" failed: ${errMsg}`);
348
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
349
- state: 'failed',
350
- tool: effect.name,
351
- error: errMsg,
352
- });
353
- sendTelemetry(
354
- deps.telemetryClient,
355
- buildToolIngestPayload({
356
- sessionId: effect.sessionId,
357
- tool: effect.name,
358
- durationMs: Date.now() - toolT0,
359
- ok: false,
360
- runId: effect.runId,
361
- stateId: effect.stateId,
362
- }),
363
- isTelemetryIngestEnabled(deps.telemetryIngest),
364
- );
365
- return { ok: false, error: errMsg } satisfies ToolCallResult;
366
- }
367
385
  }
368
386
 
369
387
  case 'send-message': {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement,
3
3
  getAgreementStatus, listAgreements, getMyAgreements, getAgreement,
4
- publishOffer as publishOfferApi, createAgreement, revokeAgreement, getAgreementsByChat, linkAgreementToChat,
4
+ publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat,
5
5
  getChatsForAgreement, joinAgreement, linkArtifactToAgreement, getArtifactsForAgreement,
6
6
  linkUserToAgreement, getUsersForAgreement,
7
7
  type Creds, type Agreement,
@@ -84,8 +84,8 @@ export class AgreementService {
84
84
  return revokeAgreement(agreementId, this.creds);
85
85
  }
86
86
 
87
- getByChat(chatId: string): Promise<unknown[]> {
88
- return getAgreementsByChat(chatId, this.creds);
87
+ fulfill(agreementId: string): Promise<{ ok: boolean; agreement: Agreement }> {
88
+ return fulfillAgreement(agreementId, this.creds);
89
89
  }
90
90
 
91
91
  linkToChat(agreementId: string, chatId: string, linkType: ChatLinkType = 'mention'): Promise<unknown | null> {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createTask, getTask, getActiveTasksForAgent, getActiveTasksForChat, getSubtasks,
3
- updateTaskState, cancelTask,
3
+ updateTaskState, updateTaskPlanStep, cancelTask,
4
4
  reportTask, setTaskSatisfaction, countTasks,
5
5
  type Creds, type Task, type TaskState,
6
6
  } from '@ziggs-ai/api-client';
@@ -50,14 +50,15 @@ export class TaskService {
50
50
  return getSubtasks(parentTaskId, this.creds);
51
51
  }
52
52
 
53
- async spawnUnderAgreement(agreementId: string, taskData: { description: string; parentTaskId?: string; plan?: unknown; planReviewTiming?: string; requireMidWorkPlanAck?: boolean; assigneeId?: string }): Promise<Task> {
53
+ async spawnUnderAgreement(agreementId: string, taskData: { description: string; parentTaskId?: string; plan?: unknown; planReviewTiming?: string; requireMidWorkPlanAck?: boolean; assigneeId?: string; inputArtifactIds?: string[] }): Promise<Task> {
54
54
  if (!agreementId) throw new Error('TaskService.spawnUnderAgreement: agreementId is required');
55
55
  try {
56
56
  const result = await createTask({ ...taskData, agreementId } as Parameters<typeof createTask>[0], this.creds);
57
57
  runtimeLog.info('TaskService', `Task spawned under agreement ${agreementId}: ${result?.taskId || 'unknown'}`);
58
58
  return result;
59
59
  } catch (error) {
60
- runtimeLog.error('TaskService', `Task spawn failed: ${(error as Error).message}`);
60
+ const e = error as { message?: string; status?: number; body?: string };
61
+ runtimeLog.error('TaskService', `Task spawn failed (agreement ${agreementId}, assignee ${taskData.assigneeId ?? '-'}): status=${e.status ?? '?'} ${e.message}${e.body ? ` | body=${e.body}` : ''}`);
61
62
  throw error;
62
63
  }
63
64
  }
@@ -74,6 +75,17 @@ export class TaskService {
74
75
  }
75
76
  }
76
77
 
78
+ async updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task> {
79
+ try {
80
+ const updated = await updateTaskPlanStep(taskId, stepId, status, result, this.creds);
81
+ runtimeLog.info('TaskService', `Task plan step: ${taskId}/${stepId} → ${status}`);
82
+ return updated;
83
+ } catch (error) {
84
+ runtimeLog.error('TaskService', `Task plan step update failed: ${(error as Error).message}`);
85
+ throw error;
86
+ }
87
+ }
88
+
77
89
  async cancel(taskId: string): Promise<Task> {
78
90
  try {
79
91
  const updated = await cancelTask(taskId, this.creds);