@ziggs-ai/agent-sdk 0.7.1 → 0.8.1
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/dist/agent/ClaudeSdkAgent.d.ts +15 -0
- package/dist/agent/ClaudeSdkAgent.js +31 -0
- package/dist/agent/claudeCognition.js +17 -2
- package/dist/agent/claudeSdkTools.js +9 -7
- package/dist/formatters/HistoryFormatter.js +5 -0
- package/dist/ingress/normalizeIncoming.d.ts +19 -5
- package/dist/ingress/normalizeIncoming.js +78 -10
- package/dist/runtime/AgentMachine.js +11 -3
- package/dist/runtime/runTurn.js +25 -11
- package/dist/runtime/wakeWriteKeys.d.ts +7 -5
- package/dist/runtime/wakeWriteKeys.js +10 -7
- package/dist/server/agreements/AgreementService.d.ts +6 -4
- package/dist/server/agreements/AgreementService.js +4 -2
- package/dist/types.d.ts +12 -2
- package/package.json +2 -2
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import type { Workflow } from '../types.js';
|
|
3
3
|
import type { ToolManager } from '../tools/ToolManager.js';
|
|
4
|
+
import { type RawEvent } from '../runtime/buildOutcome.js';
|
|
4
5
|
import type { TickInput, TickOutput } from './Agent.js';
|
|
6
|
+
/**
|
|
7
|
+
* ZIG-1035 / ZIG-1052: status notices must not spend a Claude turn answering
|
|
8
|
+
* nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
|
|
9
|
+
* from `wakeTaskAssignment`, or `agreement_lifecycle` (hire / proposal
|
|
10
|
+
* resolution — the FSM kickoffs on the same event; Claude must act too).
|
|
11
|
+
*
|
|
12
|
+
* Intentionally not mirrored as an FSM "notice skip": the FSM already no-ops
|
|
13
|
+
* enter-only outcomes in AgentMachine; `agreement_lifecycle` never produces
|
|
14
|
+
* `enter`, so hire wakes always step. Claude's filter is narrower — pure
|
|
15
|
+
* `notification` only.
|
|
16
|
+
*
|
|
17
|
+
* Empty / missing batch → notice-only (do not burn a turn on a malformed wake).
|
|
18
|
+
*/
|
|
19
|
+
export declare function isNoticeOnlyWake(event: RawEvent | undefined): boolean;
|
|
5
20
|
export type CognitionKind = 'fsm' | 'claude-sdk';
|
|
6
21
|
export interface ClaudeSdkAgentOptions {
|
|
7
22
|
workflow: Workflow;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { outcomeFromEvent } from '../runtime/buildOutcome.js';
|
|
3
|
+
import { getBatchEvents } from '../context/batch.js';
|
|
3
4
|
import { bootstrapCtx } from '../runtime/bootstrapCtx.js';
|
|
4
5
|
import { createWakeWriteKeys } from '../runtime/wakeWriteKeys.js';
|
|
5
6
|
import { CLAUDE_SDK_PROTOCOL } from './claudeSdkProtocol.js';
|
|
@@ -7,6 +8,25 @@ import { buildZiggsSdkMcpServer } from './toolManagerToSdkMcp.js';
|
|
|
7
8
|
import { claudeQueryOptions, buildClaudeEventPrompt, summarizeContext, extractAssistantText, } from './claudeCognition.js';
|
|
8
9
|
import { assertHostedAgentZiggsTools } from './hostedAgentConnection.js';
|
|
9
10
|
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
11
|
+
/**
|
|
12
|
+
* ZIG-1035 / ZIG-1052: status notices must not spend a Claude turn answering
|
|
13
|
+
* nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
|
|
14
|
+
* from `wakeTaskAssignment`, or `agreement_lifecycle` (hire / proposal
|
|
15
|
+
* resolution — the FSM kickoffs on the same event; Claude must act too).
|
|
16
|
+
*
|
|
17
|
+
* Intentionally not mirrored as an FSM "notice skip": the FSM already no-ops
|
|
18
|
+
* enter-only outcomes in AgentMachine; `agreement_lifecycle` never produces
|
|
19
|
+
* `enter`, so hire wakes always step. Claude's filter is narrower — pure
|
|
20
|
+
* `notification` only.
|
|
21
|
+
*
|
|
22
|
+
* Empty / missing batch → notice-only (do not burn a turn on a malformed wake).
|
|
23
|
+
*/
|
|
24
|
+
export function isNoticeOnlyWake(event) {
|
|
25
|
+
const events = getBatchEvents(event);
|
|
26
|
+
if (events.length === 0)
|
|
27
|
+
return true;
|
|
28
|
+
return events.every((ev) => ev?.type === 'notification');
|
|
29
|
+
}
|
|
10
30
|
const DEFAULT_STATE = 'ready';
|
|
11
31
|
/**
|
|
12
32
|
* Per-task turn cap. A wake drives one `query()` that may loop up to this many
|
|
@@ -186,6 +206,17 @@ export class ClaudeSdkAgent {
|
|
|
186
206
|
};
|
|
187
207
|
}
|
|
188
208
|
nextCtx.recentWakes = [...recentWakes, now];
|
|
209
|
+
if (isNoticeOnlyWake(rawEvent)) {
|
|
210
|
+
runtimeLog.info('ClaudeSdkAgent', `notice-only wake — skipping LLM session=${identity.sessionId} agent=${this.agentId} ` +
|
|
211
|
+
`type=${rawEvent?.type ?? 'unknown'}`);
|
|
212
|
+
return {
|
|
213
|
+
ctx: nextCtx,
|
|
214
|
+
state: state ?? DEFAULT_STATE,
|
|
215
|
+
parked: true,
|
|
216
|
+
stateTimeoutUnhandled: false,
|
|
217
|
+
metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
189
220
|
let contextSnap = null;
|
|
190
221
|
try {
|
|
191
222
|
contextSnap = await eff({
|
|
@@ -72,12 +72,27 @@ export function summarizeContext(snap) {
|
|
|
72
72
|
function renderEventLines(event, lines) {
|
|
73
73
|
if (event?.type)
|
|
74
74
|
lines.push(`Event type: ${event.type}`);
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// ZIG-1035 / ZIG-1052: notifications are not counterparty speech.
|
|
76
|
+
// agreement_lifecycle is a work signal (hire approval) — label it as such.
|
|
77
|
+
if (event?.text) {
|
|
78
|
+
const label = event.type === 'agreement_lifecycle'
|
|
79
|
+
? 'Agreement lifecycle'
|
|
80
|
+
: event.type === 'notification' || event.entryType === 'notification'
|
|
81
|
+
? 'Notification'
|
|
82
|
+
: 'Message';
|
|
83
|
+
lines.push(`${label}: ${event.text}`);
|
|
84
|
+
}
|
|
77
85
|
if (event?.senderId)
|
|
78
86
|
lines.push(`Sender: ${event.senderId}`);
|
|
79
87
|
if (event?.operation)
|
|
80
88
|
lines.push(`Operation: ${event.operation}`);
|
|
89
|
+
if (event?.type === 'agreement_lifecycle' &&
|
|
90
|
+
(event.operation === 'proposal_approved' || event.operation === 'proposal_approved_execute')) {
|
|
91
|
+
lines.push('Work signal: proposal approved. If you proposed this agreement, spawn the first task (task_create) under it now. If you are the hired provider, begin fulfilling per the agreement — do not ignore this wake.');
|
|
92
|
+
}
|
|
93
|
+
if (typeof event?.agreementId === 'string' && event.agreementId) {
|
|
94
|
+
lines.push(`Agreement: ${event.agreementId}`);
|
|
95
|
+
}
|
|
81
96
|
// ZIG-832: surface the task/agreement this event carries. Without it the brain
|
|
82
97
|
// can't tell WHICH task to update/complete (workers otherwise stall asking for
|
|
83
98
|
// their own taskId) and can't see whether the referenced step already ran or
|
|
@@ -47,19 +47,21 @@ export const chatSendTool = defineTool('chat_send', {
|
|
|
47
47
|
const receiverId = args.receiverId ?? c.senderId;
|
|
48
48
|
if (!receiverId)
|
|
49
49
|
throw new Error('chat_send: receiverId is required (this wake has no sender to default to)');
|
|
50
|
-
// Default to the wake's
|
|
51
|
-
// re-derives the same messageId and the backend drops
|
|
52
|
-
// (ZIG-718)
|
|
53
|
-
// of
|
|
54
|
-
//
|
|
50
|
+
// Default to the wake's content-derived key (ZIG-1045): a crash-replay that
|
|
51
|
+
// re-sends the same text re-derives the same messageId and the backend drops
|
|
52
|
+
// the duplicate (ZIG-718); a re-think with different text gets a new key
|
|
53
|
+
// instead of colliding on a per-wake ordinal. An explicit LLM idempotencyKey
|
|
54
|
+
// still wins (retry-safe resend of the same logical message across wakes);
|
|
55
|
+
// random only when the wake has no durable identity.
|
|
56
|
+
const text = String(args.text);
|
|
55
57
|
const messageId = args.idempotencyKey
|
|
56
58
|
? `sdk_idem_${String(args.idempotencyKey)}`
|
|
57
|
-
: c.wakeKeys?.messageKey() ?? `sdk_${randomUUID()}`;
|
|
59
|
+
: c.wakeKeys?.messageKey({ text, sessionId, receiverId }) ?? `sdk_${randomUUID()}`;
|
|
58
60
|
return c.eff({
|
|
59
61
|
kind: 'send-message',
|
|
60
62
|
sessionId,
|
|
61
63
|
receiverId,
|
|
62
|
-
text
|
|
64
|
+
text,
|
|
63
65
|
messageId,
|
|
64
66
|
});
|
|
65
67
|
}, { description: 'Send a user-visible chat message. Conversation only — never use for finished-work reporting.' });
|
|
@@ -24,6 +24,11 @@ export class HistoryFormatter {
|
|
|
24
24
|
const label = labelTypes ? '[CHAT] ' : '';
|
|
25
25
|
return `${label}${this.formatMessage(entry, agentId, timePrefix)}`;
|
|
26
26
|
}
|
|
27
|
+
// ZIG-1035: notices are not counterparty turns — no "from → to:" speech framing.
|
|
28
|
+
if (entryType === 'notification') {
|
|
29
|
+
const label = labelTypes ? '[NOTICE] ' : '';
|
|
30
|
+
return `${label}${timePrefix}[notice] ${entry['text'] || ''}`;
|
|
31
|
+
}
|
|
27
32
|
if (entryType === 'task_history') {
|
|
28
33
|
const label = labelTypes ? '[TASK] ' : '';
|
|
29
34
|
return `${label}${this.formatTaskHistory(entry, agentId, timePrefix)}`;
|
|
@@ -11,7 +11,14 @@ export interface NormalizedEvent {
|
|
|
11
11
|
*/
|
|
12
12
|
messageId?: string;
|
|
13
13
|
timestamp: number;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `notification` = lifecycle / status notice (ZIG-1035). Not counterparty
|
|
16
|
+
* speech; Claude parks without a model turn. Terminal task notices still
|
|
17
|
+
* use `task_result` so orchestrators see completions.
|
|
18
|
+
*/
|
|
19
|
+
type: 'task_result' | 'message' | 'agreement_lifecycle' | 'notification';
|
|
20
|
+
/** Wire `entryType` when present (kept so brains can tell notice vs speech). */
|
|
21
|
+
entryType?: string;
|
|
15
22
|
operation?: string;
|
|
16
23
|
agreementId?: string;
|
|
17
24
|
result?: unknown;
|
|
@@ -57,7 +64,14 @@ interface WireMetadata {
|
|
|
57
64
|
service?: {
|
|
58
65
|
task?: WireTask;
|
|
59
66
|
operation?: string;
|
|
60
|
-
agreement?:
|
|
67
|
+
agreement?: {
|
|
68
|
+
agreementId?: string;
|
|
69
|
+
status?: string;
|
|
70
|
+
proposal?: {
|
|
71
|
+
status?: string;
|
|
72
|
+
};
|
|
73
|
+
parties?: WireParties;
|
|
74
|
+
};
|
|
61
75
|
agreementId?: string;
|
|
62
76
|
};
|
|
63
77
|
[key: string]: unknown;
|
|
@@ -72,9 +86,9 @@ interface NormalizeArgs {
|
|
|
72
86
|
* backend policy now (delivery law, phase 4): the inbox loop only ever delivers
|
|
73
87
|
* what is fenced to this agent, so there is no client-side "is this for me"
|
|
74
88
|
* relevance gate — this function decides SHAPE (message vs task_result vs
|
|
75
|
-
* agreement_lifecycle) and the fields the FSM / SessionResolver /
|
|
76
|
-
* depend on, nothing more. `ownAgentId` is accepted for call-site
|
|
77
|
-
* but no longer gates anything.
|
|
89
|
+
* agreement_lifecycle vs notification) and the fields the FSM / SessionResolver /
|
|
90
|
+
* wakeWriteKeys depend on, nothing more. `ownAgentId` is accepted for call-site
|
|
91
|
+
* compatibility but no longer gates anything.
|
|
78
92
|
*/
|
|
79
93
|
export declare function normalizeIncomingEvent({ text, metadata }: NormalizeArgs): NormalizedEvent;
|
|
80
94
|
export {};
|
|
@@ -19,21 +19,53 @@ function isTaskRelated(metadata, taskData) {
|
|
|
19
19
|
|| contentType === 'task' || contentType === 'task_update'
|
|
20
20
|
|| Boolean(taskData.state || taskData.proposal || taskData.parentTaskId || taskData.executorId || taskData.agentId || taskData.payerId || taskData.createdBy));
|
|
21
21
|
}
|
|
22
|
+
function wireOperation(metadata) {
|
|
23
|
+
const op = metadata.service?.operation ?? metadata.operation;
|
|
24
|
+
return typeof op === 'string' ? op : undefined;
|
|
25
|
+
}
|
|
26
|
+
function wireAgreementId(metadata) {
|
|
27
|
+
const raw = metadata.service?.agreementId ??
|
|
28
|
+
metadata.service?.agreement?.agreementId ??
|
|
29
|
+
metadata.agreementId;
|
|
30
|
+
return typeof raw === 'string' ? raw : '';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Self-hire / fully-consented create activates before the chat fan-out lands.
|
|
34
|
+
* The wire still says `proposal_created`, but the embedded agreement is already
|
|
35
|
+
* `active` / proposal `approved`. That is the hire work signal (same as
|
|
36
|
+
* `proposal_approved_execute`) — Claude must not park it as a notice (ZIG-1052).
|
|
37
|
+
*/
|
|
38
|
+
function isAlreadyActivatedProposalCreated(metadata) {
|
|
39
|
+
if (wireOperation(metadata) !== 'proposal_created')
|
|
40
|
+
return false;
|
|
41
|
+
const agr = metadata.service?.agreement;
|
|
42
|
+
if (!agr || typeof agr !== 'object')
|
|
43
|
+
return false;
|
|
44
|
+
const status = agr.status;
|
|
45
|
+
const proposalStatus = agr.proposal?.status;
|
|
46
|
+
return status === 'active' || proposalStatus === 'approved';
|
|
47
|
+
}
|
|
22
48
|
function isAgreementLifecycleNotification(metadata) {
|
|
23
49
|
const entryType = metadata.entryType || 'message';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
50
|
+
if (entryType !== 'notification')
|
|
51
|
+
return false;
|
|
52
|
+
const operation = wireOperation(metadata);
|
|
53
|
+
if (typeof operation === 'string' && PROPOSAL_RESOLVED_OPERATIONS.has(operation)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return isAlreadyActivatedProposalCreated(metadata);
|
|
57
|
+
}
|
|
58
|
+
function isTerminalTaskState(state) {
|
|
59
|
+
return state === 'completed' || state === 'failed' || state === 'cancelled';
|
|
28
60
|
}
|
|
29
61
|
/**
|
|
30
62
|
* Classify + shape a wire delivery into a lane-routable event. Targeting is
|
|
31
63
|
* backend policy now (delivery law, phase 4): the inbox loop only ever delivers
|
|
32
64
|
* what is fenced to this agent, so there is no client-side "is this for me"
|
|
33
65
|
* relevance gate — this function decides SHAPE (message vs task_result vs
|
|
34
|
-
* agreement_lifecycle) and the fields the FSM / SessionResolver /
|
|
35
|
-
* depend on, nothing more. `ownAgentId` is accepted for call-site
|
|
36
|
-
* but no longer gates anything.
|
|
66
|
+
* agreement_lifecycle vs notification) and the fields the FSM / SessionResolver /
|
|
67
|
+
* wakeWriteKeys depend on, nothing more. `ownAgentId` is accepted for call-site
|
|
68
|
+
* compatibility but no longer gates anything.
|
|
37
69
|
*/
|
|
38
70
|
export function normalizeIncomingEvent({ text, metadata = {} }) {
|
|
39
71
|
// ZIG-936: classifier data lives under `service` on the inbox-pull wire;
|
|
@@ -42,17 +74,22 @@ export function normalizeIncomingEvent({ text, metadata = {} }) {
|
|
|
42
74
|
const chatId = normalizeChatId(metadata);
|
|
43
75
|
const receiverId = normalizeReceiverId(metadata, taskData);
|
|
44
76
|
const senderType = metadata.sender?.type ? String(metadata.sender.type).toUpperCase() : undefined;
|
|
77
|
+
const entryType = typeof metadata.entryType === 'string' && metadata.entryType
|
|
78
|
+
? metadata.entryType
|
|
79
|
+
: undefined;
|
|
45
80
|
const base = {
|
|
46
81
|
senderId: metadata.sender?.id,
|
|
47
82
|
senderType,
|
|
48
83
|
receiverId: receiverId || undefined,
|
|
49
84
|
messageId: typeof metadata.messageId === 'string' && metadata.messageId ? metadata.messageId : undefined,
|
|
50
85
|
timestamp: Date.now(),
|
|
86
|
+
...(entryType ? { entryType } : {}),
|
|
51
87
|
};
|
|
52
88
|
if (isAgreementLifecycleNotification(metadata)) {
|
|
53
|
-
const operation = metadata
|
|
54
|
-
|
|
55
|
-
|
|
89
|
+
const operation = isAlreadyActivatedProposalCreated(metadata)
|
|
90
|
+
? 'proposal_approved_execute'
|
|
91
|
+
: wireOperation(metadata);
|
|
92
|
+
const agreementId = wireAgreementId(metadata);
|
|
56
93
|
return {
|
|
57
94
|
chatId,
|
|
58
95
|
event: {
|
|
@@ -65,11 +102,42 @@ export function normalizeIncomingEvent({ text, metadata = {} }) {
|
|
|
65
102
|
};
|
|
66
103
|
}
|
|
67
104
|
if (isTaskRelated(metadata, taskData)) {
|
|
105
|
+
// ZIG-1035: active/spawn chat notices are not work — real assignment arrives
|
|
106
|
+
// on the task channel (`tasksAwaitingMe` → wakeTaskAssignment). Keep terminal
|
|
107
|
+
// task notices as `task_result` so orchestrators still see completions.
|
|
108
|
+
if (entryType === 'notification' && !isTerminalTaskState(taskData?.state)) {
|
|
109
|
+
return {
|
|
110
|
+
chatId,
|
|
111
|
+
event: {
|
|
112
|
+
...base,
|
|
113
|
+
type: 'notification',
|
|
114
|
+
result: taskData,
|
|
115
|
+
text,
|
|
116
|
+
operation: metadata.service?.operation ?? metadata.operation,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
68
120
|
return {
|
|
69
121
|
chatId,
|
|
70
122
|
event: { ...base, type: 'task_result', result: taskData, text },
|
|
71
123
|
};
|
|
72
124
|
}
|
|
125
|
+
// ZIG-1035: residual lifecycle notices (proposal_created, agreement active, …)
|
|
126
|
+
// must not fall through as counterparty `message` speech.
|
|
127
|
+
if (entryType === 'notification') {
|
|
128
|
+
const operation = metadata.service?.operation ?? metadata.operation;
|
|
129
|
+
const rawAgreementId = metadata.service?.agreementId ?? metadata.agreementId;
|
|
130
|
+
return {
|
|
131
|
+
chatId,
|
|
132
|
+
event: {
|
|
133
|
+
...base,
|
|
134
|
+
type: 'notification',
|
|
135
|
+
text,
|
|
136
|
+
...(typeof operation === 'string' ? { operation } : {}),
|
|
137
|
+
...(typeof rawAgreementId === 'string' ? { agreementId: rawAgreementId } : {}),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
73
141
|
return {
|
|
74
142
|
chatId,
|
|
75
143
|
event: { ...base, type: 'message', text },
|
|
@@ -356,9 +356,17 @@ export class AgentMachine {
|
|
|
356
356
|
if (!userId)
|
|
357
357
|
return;
|
|
358
358
|
try {
|
|
359
|
-
//
|
|
360
|
-
// again and re-sends this apology — same key, backend
|
|
361
|
-
|
|
359
|
+
// Content-derived wake messageId (ZIG-1045): a crash-replay that errors
|
|
360
|
+
// the same way parks again and re-sends this apology — same key, backend
|
|
361
|
+
// drops the dupe.
|
|
362
|
+
const sessionId = this.ctx.identity.sessionId;
|
|
363
|
+
await this.eff({
|
|
364
|
+
kind: 'send-message',
|
|
365
|
+
sessionId,
|
|
366
|
+
receiverId: userId,
|
|
367
|
+
text,
|
|
368
|
+
messageId: this.wakeKeys?.messageKey({ text, sessionId, receiverId: userId }),
|
|
369
|
+
});
|
|
362
370
|
}
|
|
363
371
|
catch { /* best-effort */ }
|
|
364
372
|
}
|
package/dist/runtime/runTurn.js
CHANGED
|
@@ -65,7 +65,15 @@ export async function runTurn(input) {
|
|
|
65
65
|
return eff({ ...effect, runId, stateId, wakeKeys });
|
|
66
66
|
}
|
|
67
67
|
if (effect.kind === 'send-message' && !effect.messageId) {
|
|
68
|
-
|
|
68
|
+
const send = effect;
|
|
69
|
+
return eff({
|
|
70
|
+
...effect,
|
|
71
|
+
messageId: wakeKeys?.messageKey({
|
|
72
|
+
text: send.text,
|
|
73
|
+
sessionId: send.sessionId,
|
|
74
|
+
receiverId: send.receiverId,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
69
77
|
}
|
|
70
78
|
return eff(effect);
|
|
71
79
|
};
|
|
@@ -316,11 +324,12 @@ export async function runTurn(input) {
|
|
|
316
324
|
const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
|
|
317
325
|
runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
|
|
318
326
|
if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
// Live chunks use a provisional id (text not known yet). The persisted
|
|
328
|
+
// send below switches to a content-derived wake key (ZIG-1045) once the
|
|
329
|
+
// reply text exists — a crash-replay of the same text reuses that key
|
|
330
|
+
// (ZIG-718), and a re-think with different text does not collide.
|
|
331
|
+
const chunkMessageId = crypto.randomUUID();
|
|
332
|
+
runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${chunkMessageId}`);
|
|
324
333
|
const streamMessages = [
|
|
325
334
|
...messages,
|
|
326
335
|
{
|
|
@@ -334,7 +343,7 @@ export async function runTurn(input) {
|
|
|
334
343
|
];
|
|
335
344
|
let replyText = '';
|
|
336
345
|
try {
|
|
337
|
-
const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId:
|
|
346
|
+
const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: chunkMessageId, messages: streamMessages });
|
|
338
347
|
replyText = streamResult.text ?? '';
|
|
339
348
|
// Count the streamed reply only when it actually produced the text we
|
|
340
349
|
// send (an empty resolve falls through to the llm-call fallback below,
|
|
@@ -356,6 +365,7 @@ export async function runTurn(input) {
|
|
|
356
365
|
// llm-call + send-message below so the user still gets a reply.
|
|
357
366
|
runtimeLog.error('runTurn', `stream-text failed: ${err.message} — falling back to non-streaming send`);
|
|
358
367
|
}
|
|
368
|
+
let persistMessageId = chunkMessageId;
|
|
359
369
|
try {
|
|
360
370
|
if (!replyText) {
|
|
361
371
|
const fallbackResponse = await effWithTrace({ kind: 'llm-call', messages: streamMessages, tools: [], sessionId });
|
|
@@ -369,16 +379,20 @@ export async function runTurn(input) {
|
|
|
369
379
|
throw new Error('fallback llm-call returned empty reply text');
|
|
370
380
|
}
|
|
371
381
|
const safeText = truncateMsg(replyText);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
382
|
+
persistMessageId =
|
|
383
|
+
wakeKeys?.messageKey({
|
|
384
|
+
text: safeText,
|
|
385
|
+
sessionId,
|
|
386
|
+
receiverId: decision.receiverId,
|
|
387
|
+
}) ?? chunkMessageId;
|
|
388
|
+
await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: persistMessageId });
|
|
375
389
|
allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
|
|
376
390
|
lastAction = chosenAction;
|
|
377
391
|
lastActionDef = actionDef;
|
|
378
392
|
}
|
|
379
393
|
catch (err) {
|
|
380
394
|
if (isReplySlotTaken(err)) {
|
|
381
|
-
runtimeLog.warn('runTurn', `reply already persisted under ${
|
|
395
|
+
runtimeLog.warn('runTurn', `reply already persisted under ${persistMessageId} (idempotency 409) — a replay or second host answered this wake first; dropping duplicate`);
|
|
382
396
|
allEmittedEvents.push({ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: sessionId, senderId: 'system' });
|
|
383
397
|
lastAction = chosenAction;
|
|
384
398
|
lastActionDef = actionDef;
|
|
@@ -9,11 +9,13 @@ import type { WakeWriteKeys } from '../types.js';
|
|
|
9
9
|
* - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
|
|
10
10
|
* the transition itself, so it dedups regardless of the order in which the
|
|
11
11
|
* replayed wake happens to re-issue its tool calls.
|
|
12
|
-
* - chat messages: `sdk_idem_wk_<wake-hash>
|
|
13
|
-
* the messageId (the backend dedups on (chatId, messageId),
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* - chat messages: `sdk_idem_wk_<wake-hash>_m_<hash(chat, receiver, text)>`
|
|
13
|
+
* — used directly as the messageId (the backend dedups on (chatId, messageId),
|
|
14
|
+
* ZIG-718). Content-derived (ZIG-1045), not ordinal: a wake may emit several
|
|
15
|
+
* distinct messages without colliding, and a crash-replay that re-sends the
|
|
16
|
+
* same text reuses the key; a re-think with different text gets a new key
|
|
17
|
+
* instead of 409-dropping on the old ordinal slot. The prefix matches the
|
|
18
|
+
* tool-layer convention for deterministic ids (`sdk_idem_<llm-key>`).
|
|
17
19
|
* - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
|
|
18
20
|
* derived from what the spawn writes, so it dedups regardless of re-issue
|
|
19
21
|
* order while still distinguishing multiple spawns in one wake.
|
|
@@ -78,11 +78,13 @@ function sha(input) {
|
|
|
78
78
|
* - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
|
|
79
79
|
* the transition itself, so it dedups regardless of the order in which the
|
|
80
80
|
* replayed wake happens to re-issue its tool calls.
|
|
81
|
-
* - chat messages: `sdk_idem_wk_<wake-hash>
|
|
82
|
-
* the messageId (the backend dedups on (chatId, messageId),
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
81
|
+
* - chat messages: `sdk_idem_wk_<wake-hash>_m_<hash(chat, receiver, text)>`
|
|
82
|
+
* — used directly as the messageId (the backend dedups on (chatId, messageId),
|
|
83
|
+
* ZIG-718). Content-derived (ZIG-1045), not ordinal: a wake may emit several
|
|
84
|
+
* distinct messages without colliding, and a crash-replay that re-sends the
|
|
85
|
+
* same text reuses the key; a re-think with different text gets a new key
|
|
86
|
+
* instead of 409-dropping on the old ordinal slot. The prefix matches the
|
|
87
|
+
* tool-layer convention for deterministic ids (`sdk_idem_<llm-key>`).
|
|
86
88
|
* - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
|
|
87
89
|
* derived from what the spawn writes, so it dedups regardless of re-issue
|
|
88
90
|
* order while still distinguishing multiple spawns in one wake.
|
|
@@ -107,10 +109,11 @@ export function createWakeWriteKeys(laneKey, event) {
|
|
|
107
109
|
return undefined;
|
|
108
110
|
return `wk_${wakeHash}_t_${sha(`${taskId}\n${state}`).slice(0, 16)}`;
|
|
109
111
|
},
|
|
110
|
-
messageKey() {
|
|
112
|
+
messageKey(fields) {
|
|
111
113
|
if (wakeHash === null)
|
|
112
114
|
return undefined;
|
|
113
|
-
|
|
115
|
+
const msgHash = sha(`${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`).slice(0, 16);
|
|
116
|
+
return `sdk_idem_wk_${wakeHash}_m_${msgHash}`;
|
|
114
117
|
},
|
|
115
118
|
taskSpawnKey(fields) {
|
|
116
119
|
if (wakeHash === null)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Agreement, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateAgreementBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
|
|
1
|
+
import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateAgreementBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
|
|
2
2
|
export declare class AgreementService {
|
|
3
3
|
private creds;
|
|
4
4
|
constructor(operatorKey: string, agentId: string);
|
|
@@ -20,12 +20,14 @@ export declare class AgreementService {
|
|
|
20
20
|
delegate(payload: DelegateAgreementData): Promise<Agreement>;
|
|
21
21
|
respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
|
|
22
22
|
/**
|
|
23
|
-
* ZIG-1021 — claim any open broadcast (quest, standing offer,
|
|
24
|
-
* Routing (offer vs agreement claim endpoint) is decided in
|
|
23
|
+
* ZIG-1021 — claim any open broadcast (quest, standing offer, hand-off,
|
|
24
|
+
* link invite). Routing (offer vs agreement claim endpoint) is decided in
|
|
25
|
+
* the shared flow; `kind` is the api-client ClaimedKind so this signature
|
|
26
|
+
* can't drift from the flow again (a 'hand-off' addition broke the build).
|
|
25
27
|
*/
|
|
26
28
|
claim(agreementId: string): Promise<{
|
|
27
29
|
agreement: Agreement;
|
|
28
|
-
kind:
|
|
30
|
+
kind: ClaimedKind;
|
|
29
31
|
}>;
|
|
30
32
|
/**
|
|
31
33
|
* ZIG-1022 — a link proposal is an agreement (engagementKind 'link') to one
|
|
@@ -38,8 +38,10 @@ export class AgreementService {
|
|
|
38
38
|
return respondToAgreement(agreementId, action, this.creds);
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
|
-
* ZIG-1021 — claim any open broadcast (quest, standing offer,
|
|
42
|
-
* Routing (offer vs agreement claim endpoint) is decided in
|
|
41
|
+
* ZIG-1021 — claim any open broadcast (quest, standing offer, hand-off,
|
|
42
|
+
* link invite). Routing (offer vs agreement claim endpoint) is decided in
|
|
43
|
+
* the shared flow; `kind` is the api-client ClaimedKind so this signature
|
|
44
|
+
* can't drift from the flow again (a 'hand-off' addition broke the build).
|
|
43
45
|
*/
|
|
44
46
|
claim(agreementId) {
|
|
45
47
|
return claimOpenAgreement(agreementId, this.creds);
|
package/dist/types.d.ts
CHANGED
|
@@ -389,8 +389,18 @@ export interface WakeWriteKeys {
|
|
|
389
389
|
artifactKey(): string | undefined;
|
|
390
390
|
/** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
|
|
391
391
|
taskStateKey(taskId: string, state: string): string | undefined;
|
|
392
|
-
/**
|
|
393
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Default messageId for a chat-message send of this wake — the backend dedups
|
|
394
|
+
* on (chatId, messageId). Derived from the emission's content (text + chat +
|
|
395
|
+
* receiver), not a per-wake ordinal (ZIG-1045): a wake that says two things
|
|
396
|
+
* gets two keys, and a redelivered wake that re-says the same text reuses the
|
|
397
|
+
* same key, while a re-think with different text does not collide and drop.
|
|
398
|
+
*/
|
|
399
|
+
messageKey(fields: {
|
|
400
|
+
text: string;
|
|
401
|
+
sessionId?: string;
|
|
402
|
+
receiverId?: string;
|
|
403
|
+
}): string | undefined;
|
|
394
404
|
/** Key for a task spawn. Derived from what the spawn writes (agreement, description, assignee, parent), so it is stable regardless of tool-call order within the wake. */
|
|
395
405
|
taskSpawnKey(fields: {
|
|
396
406
|
agreementId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@anthropic-ai/claude-agent-sdk": "^0.3.204",
|
|
27
27
|
"@anthropic-ai/sdk": "^0.110.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
-
"@ziggs-ai/api-client": "^0.
|
|
29
|
+
"@ziggs-ai/api-client": "^0.7.1",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
31
|
"dotenv": "^17.2.3",
|
|
32
32
|
"openai": "^4.0.0",
|