@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.
@@ -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,10 +344,12 @@ 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) => {
352
+ const streamed = await deps.llm.chatMessagesStream(messages as LlmMessage[], (chunk) => {
283
353
  // Live-stream display is best-effort. The transport's send rejects
284
354
  // empty/whitespace-only content (WebSocketClient: content.trim() === ''),
285
355
  // and a whitespace-only chunk (e.g. a leading newline) is truthy yet
@@ -291,88 +361,27 @@ export function createZiggsEffectHandler(deps: ZiggsEffectDeps): EffectHandler {
291
361
  if (chunk && chunk.trim()) deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
292
362
  } catch { /* ignore — streaming display only */ }
293
363
  });
364
+ text = streamed.text;
365
+ usage = streamed.usage;
294
366
  } else {
295
367
  const r = await deps.llm.chatMessages(messages as LlmMessage[]);
296
368
  text = r.content ?? '';
369
+ usage = r.usage;
297
370
  if (text) deps.chunkSender?.(sessionId, receiverId, text, messageId);
298
371
  }
299
- return { text };
372
+ return { text, usage };
300
373
  }
301
374
 
302
375
  case 'tool-call': {
303
- 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).
304
380
  const cached = cacheGet(effect.sessionId);
305
- const agents = (cached?.agents as unknown[]) || [];
306
- const users = (cached?.users as unknown[]) || [];
307
- const args = (effect.args as Record<string, unknown>) || {};
308
- const argsPreview = previewToolArgs(args);
309
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
310
- state: 'started',
311
- tool: effect.name,
312
- ...(argsPreview ? { args: argsPreview } : {}),
381
+ return executeToolWithSideEffects(deps, handler as EffectHandler, effect, {
382
+ agents: (cached?.agents as unknown[]) || [],
383
+ users: (cached?.users as unknown[]) || [],
313
384
  });
314
- try {
315
- const lastUser = (users as Array<{ userId?: string; id?: string }>).find(
316
- (u) => u.userId || u.id,
317
- );
318
- const result = await deps.toolManager.executeTool(
319
- effect.name,
320
- args,
321
- {
322
- operatorKey: deps.operatorKey,
323
- agentId: deps.agentId,
324
- chatId: effect.sessionId,
325
- taskService: deps.taskService,
326
- agreementService: deps.agreementService,
327
- sendMessage: deps.messageSender,
328
- agents,
329
- users,
330
- senderId: effect.senderId ?? lastUser?.userId ?? lastUser?.id,
331
- senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
332
- memory: effect.memory,
333
- turnScratch: {},
334
- },
335
- );
336
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'completed', {
337
- state: 'completed',
338
- tool: effect.name,
339
- result: clipForOperationArtifact(result),
340
- });
341
- sendTelemetry(
342
- deps.telemetryClient,
343
- buildToolIngestPayload({
344
- sessionId: effect.sessionId,
345
- tool: effect.name,
346
- durationMs: Date.now() - toolT0,
347
- ok: true,
348
- runId: effect.runId,
349
- stateId: effect.stateId,
350
- }),
351
- isTelemetryIngestEnabled(deps.telemetryIngest),
352
- );
353
- return { ok: true, result } satisfies ToolCallResult;
354
- } catch (error: unknown) {
355
- const errMsg = (error as Error)?.message ?? String(error);
356
- runtimeLog.warn('ZiggsEffectHandler', `tool-call "${effect.name}" failed: ${errMsg}`);
357
- await emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'error', {
358
- state: 'failed',
359
- tool: effect.name,
360
- error: errMsg,
361
- });
362
- sendTelemetry(
363
- deps.telemetryClient,
364
- buildToolIngestPayload({
365
- sessionId: effect.sessionId,
366
- tool: effect.name,
367
- durationMs: Date.now() - toolT0,
368
- ok: false,
369
- runId: effect.runId,
370
- stateId: effect.stateId,
371
- }),
372
- isTelemetryIngestEnabled(deps.telemetryIngest),
373
- );
374
- return { ok: false, error: errMsg } satisfies ToolCallResult;
375
- }
376
385
  }
377
386
 
378
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> {
@@ -57,7 +57,8 @@ export class TaskService {
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
  }