@ziggs-ai/agent-sdk 0.42.0 → 0.43.0

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 (86) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/dist/Agent.d.ts +27 -8
  4. package/dist/Agent.js +188 -98
  5. package/dist/brain/ClaudeBrain.d.ts +0 -1
  6. package/dist/brain/ClaudeBrain.js +18 -42
  7. package/dist/brain/claudeCognition.js +5 -6
  8. package/dist/brain/claudeSdkProtocol.js +9 -9
  9. package/dist/brain/claudeSdkTools.d.ts +5 -5
  10. package/dist/brain/claudeSdkTools.js +40 -135
  11. package/dist/brain/hostedToolSurface.js +1 -1
  12. package/dist/brain/standingOfferNotice.d.ts +0 -2
  13. package/dist/brain/standingOfferNotice.js +1 -21
  14. package/dist/brain/toolManagerToSdkMcp.js +1 -1
  15. package/dist/brain/wakeGate.d.ts +3 -1
  16. package/dist/brain/wakeGate.js +6 -0
  17. package/dist/index.d.ts +4 -3
  18. package/dist/index.js +4 -3
  19. package/dist/ingress/normalizeIncoming.d.ts +5 -3
  20. package/dist/ingress/normalizeIncoming.js +9 -8
  21. package/dist/mcp/createMcpAgent.d.ts +15 -0
  22. package/dist/mcp/createMcpAgent.js +47 -11
  23. package/dist/memory/MemoryStore.d.ts +1 -1
  24. package/dist/memory/MemoryStore.js +1 -6
  25. package/dist/platform/ConnectionPool.js +1 -1
  26. package/dist/platform/OutboxBuffer.d.ts +2 -2
  27. package/dist/platform/OutboxBuffer.js +17 -17
  28. package/dist/platform/StateTimers.d.ts +3 -1
  29. package/dist/platform/StateTimers.js +4 -3
  30. package/dist/platform/WakeLedger.d.ts +10 -6
  31. package/dist/platform/WakeLedger.js +16 -9
  32. package/dist/platform/ZiggsEffectHandler.d.ts +18 -4
  33. package/dist/platform/ZiggsEffectHandler.js +86 -55
  34. package/dist/platform/agreements/AgreementService.d.ts +1 -3
  35. package/dist/platform/agreements/AgreementService.js +1 -7
  36. package/dist/platform/inboxLoop.d.ts +5 -9
  37. package/dist/platform/inboxLoop.js +165 -158
  38. package/dist/platform/polls/PollKind.d.ts +13 -16
  39. package/dist/platform/polls/PollKind.js +4 -4
  40. package/dist/platform/polls/PollRunner.d.ts +3 -20
  41. package/dist/platform/polls/PollRunner.js +13 -15
  42. package/dist/platform/polls/declaredPollRunner.d.ts +1 -1
  43. package/dist/platform/polls/declaredPollRunner.js +1 -1
  44. package/dist/platform/tasks/TaskService.d.ts +4 -3
  45. package/dist/platform/tasks/TaskService.js +1 -4
  46. package/dist/platform/telemetryIngest.js +4 -4
  47. package/dist/platform/tools/index.d.ts +1 -2
  48. package/dist/platform/tools/index.js +1 -4
  49. package/dist/platform/tools/tier1/protocolRunner.d.ts +1 -1
  50. package/dist/platform/tools/tier1/protocolRunner.js +24 -17
  51. package/dist/platform/tools/tier1/protocolTools.js +2 -2
  52. package/dist/platform/tools/tier1/wakeTools.d.ts +1 -1
  53. package/dist/platform/tools/tier1/wakeTools.js +2 -1
  54. package/dist/platform/tools/tier2/mcpConnectionTools.js +1 -1
  55. package/dist/platform/tools/tier2/taskTools.d.ts +1 -0
  56. package/dist/platform/tools/tier2/taskTools.js +2 -1
  57. package/dist/runtime/AgentMachine.d.ts +2 -2
  58. package/dist/runtime/AgentMachine.js +4 -5
  59. package/dist/runtime/PromptBuilder.d.ts +1 -1
  60. package/dist/runtime/PromptBuilder.js +5 -4
  61. package/dist/runtime/buildOutcome.d.ts +1 -1
  62. package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
  63. package/dist/runtime/deliveryWriteKeys.js +1 -1
  64. package/dist/runtime/kickoff.d.ts +14 -43
  65. package/dist/runtime/kickoff.js +22 -178
  66. package/dist/runtime/messageCommand.d.ts +9 -0
  67. package/dist/runtime/messageCommand.js +23 -0
  68. package/dist/runtime/requiredContext.d.ts +6 -0
  69. package/dist/runtime/requiredContext.js +23 -0
  70. package/dist/runtime/runTurn.js +31 -106
  71. package/dist/runtime/taskIntake.js +8 -22
  72. package/dist/runtime/wakeEffects.d.ts +3 -12
  73. package/dist/runtime/wakeEffects.js +17 -48
  74. package/dist/runtime/wakeOutcome.js +1 -1
  75. package/dist/skills/createSkillAgent.js +12 -1
  76. package/dist/tools/capabilityAdapter.js +1 -1
  77. package/dist/tools/recordReport.d.ts +1 -1
  78. package/dist/tools/recordReport.js +3 -3
  79. package/dist/tools/toolCreds.d.ts +1 -1
  80. package/dist/tools/toolCreds.js +4 -2
  81. package/dist/types.d.ts +17 -27
  82. package/package.json +3 -3
  83. package/dist/platform/tools/tier2/chatTools.d.ts +0 -3
  84. package/dist/platform/tools/tier2/chatTools.js +0 -7
  85. package/dist/probeChat.d.ts +0 -10
  86. package/dist/probeChat.js +0 -12
@@ -3,11 +3,6 @@ import { PROBE_CTX } from './validateWorkflow.js';
3
3
  import { hasDelegatedAgreement, hasOpenTaskForAgreement } from './taskBookkeeping.js';
4
4
  /** Reserved state name for the injected agreement-kickoff default (proposer). */
5
5
  export const KICKOFF_STATE = 'kickingOff';
6
- /**
7
- * reserved state for the injected claim/provider kickoff default.
8
- * Store listing claimed → open buyer chat + greet (not task_create).
9
- */
10
- export const CLAIM_KICKOFF_STATE = 'claimKickingOff';
11
6
  // Synthetic outcome used to ask "does this state already handle a proposer-side
12
7
  // approval?" — the same probe pattern validateWorkflow uses for the wait/error
13
8
  // fallthrough checks.
@@ -17,26 +12,6 @@ const PROBE_APPROVE = {
17
12
  proposal: { agreementId: '__kickoff_probe__' },
18
13
  proposedByMe: true,
19
14
  };
20
- /** Probe for claim/provider kickoff — must NOT match direct-proposal approver. */
21
- const PROBE_CLAIM_APPROVE = {
22
- kind: 'proposal-resolved',
23
- action: 'approve',
24
- proposal: { agreementId: '__claim_kickoff_probe__' },
25
- proposedByMe: false,
26
- fromClaimWake: true,
27
- };
28
- /**
29
- * A bare counterparty approve — someone approved a proposal we did not make,
30
- * with no store claim behind it. Used as a NEGATIVE probe: a transition that
31
- * matches this as well as PROBE_CLAIM_APPROVE is a generic proposal-resolved
32
- * route, not a deliberate claim handler, so it does not suppress injection.
33
- */
34
- const PROBE_BARE_APPROVE = {
35
- kind: 'proposal-resolved',
36
- action: 'approve',
37
- proposal: { agreementId: '__bare_approve_probe__' },
38
- proposedByMe: false,
39
- };
40
15
  const KICKOFF_TRANSITION = {
41
16
  to: KICKOFF_STATE,
42
17
  when: (o, ctx) => o.kind === 'proposal-resolved' &&
@@ -50,15 +25,6 @@ const KICKOFF_TRANSITION = {
50
25
  // (2) we already hold an assigned task bound to it:
51
26
  !hasOpenTaskForAgreement(ctx, o.proposal.agreementId),
52
27
  };
53
- const CLAIM_KICKOFF_TRANSITION = {
54
- to: CLAIM_KICKOFF_STATE,
55
- when: (o, ctx) => o.kind === 'proposal-resolved' &&
56
- o.action === 'approve' &&
57
- o.proposedByMe === false &&
58
- o.fromClaimWake === true &&
59
- !hasDelegatedAgreement(ctx, o.proposal.agreementId) &&
60
- !hasOpenTaskForAgreement(ctx, o.proposal.agreementId),
61
- };
62
28
  // A state "handles" proposer-approval if some transition would fire for it.
63
29
  // An unconditional (when-less) transition counts: it catches every outcome,
64
30
  // so the kickoff transition — appended LAST — would be unreachable dead code
@@ -81,33 +47,6 @@ function stateHandlesProposerApproval(state) {
81
47
  }
82
48
  });
83
49
  }
84
- function matches(t, probe) {
85
- if (!t.when)
86
- return false;
87
- try {
88
- return !!t.when(probe, PROBE_CTX);
89
- }
90
- catch {
91
- return false;
92
- }
93
- }
94
- /**
95
- * A state handles the claim kickoff itself only when it says so EXPLICITLY —
96
- * a transition that fires for a store-claim wake and does NOT also fire for a
97
- * bare counterparty approve. In practice that means keying on `fromClaimWake`.
98
- *
99
- * Two weaker rules read as "handled" when nothing was. A when-less fallthrough
100
- * counted, so a state shaped `idle → listening` never got the transition
101
- * injected and routed every claim into its idle loop; and a `when: o => o.kind
102
- * === 'proposal-resolved'` route counted too, because the claim probe is also a
103
- * proposal-resolved/approve. Neither author had written anything about claims.
104
- * Requiring the negative probe to MISS is what tells a deliberate claim handler
105
- * apart from a generic one, and the unconditional case is safe to inject over
106
- * because the claim transition goes in FIRST — see injectKickoffDefault.
107
- */
108
- function stateHandlesClaimKickoff(state) {
109
- return state.transitions.some(t => matches(t, PROBE_CLAIM_APPROVE) && !matches(t, PROBE_BARE_APPROVE));
110
- }
111
50
  export function buildKickoffState(initial) {
112
51
  const dflt = thinkingDefaults({ initial });
113
52
  return {
@@ -142,27 +81,19 @@ export function buildKickoffState(initial) {
142
81
  ],
143
82
  };
144
83
  }
145
- /** provider side after a store/listing claim. */
146
84
  /**
147
- * Declare that THIS state answers a store-claim wake itself, so the injected
148
- * `claimKickingOff` greeting is not added to it.
85
+ * Route a store-claim wake from this state to `to`. The SDK no longer greets
86
+ * on a claim (a claim opens no room; the buyer writes first in the agreement's
87
+ * conversation), so this is only for an author who wants to act on the claim
88
+ * itself.
149
89
  *
150
- * The opt-out has to fire on a claim wake and NOT on a bare counterparty
151
- * approve — `stateHandlesClaimKickoff` uses that difference to tell a
152
- * deliberate claim handler from a generic `proposal-resolved` route that
153
- * happens to match. Writing that predicate by hand is easy to get subtly wrong,
154
- * and getting it wrong is silent: the agent greets on every claim while its
155
- * author believes it opted out. That cost one lab cast 100% of its token spend
156
- * and three wedged workers before anyone noticed.
90
+ * It fires on a claim wake and NOT on a bare counterparty approve, which also
91
+ * keeps it out of the way of the proposer-side kickoff.
157
92
  *
158
93
  * transitions: [
159
94
  * handlesClaimWake('idle'), // a claim does not make this agent talk
160
95
  * { to: 'idle', when: (o) => o.kind === 'proposal-resolved' && o.action === 'approve' },
161
96
  * ]
162
- *
163
- * Note the second route is still worth keeping when you also want to sit out
164
- * the PROPOSER-side kickoff: that one probes with a bare approve, which this
165
- * helper deliberately does not match.
166
97
  */
167
98
  export function handlesClaimWake(to) {
168
99
  return {
@@ -170,120 +101,33 @@ export function handlesClaimWake(to) {
170
101
  when: (o) => o.kind === 'proposal-resolved' && o.action === 'approve' && o.fromClaimWake === true,
171
102
  };
172
103
  }
173
- export function buildClaimKickoffState(initial) {
174
- const dflt = thinkingDefaults({ initial });
175
- return {
176
- kind: 'thinking',
177
- prompt: {
178
- role: "A buyer just claimed your store listing. chat_open with the buyer and the claimed agreementId returns the hire's room, which the server usually made at claim, or opens one when there is none. Then greet them and start serving the terms.",
179
- goal: "Open or reuse the hire's room with the buyer and greet them so the engagement begins.",
180
- constraints: "The claimed agreement id is in <event> / activeAgreementId. The buyer is the claim actor (Sender / actorId / Buyer on the event) — call chat_open with that participantId AND the claimed agreementId. That returns the hire's room the server usually made at claim, or opens one when there is none. Then chat_send a short greeting that acknowledges the hire and offers to serve the agreement terms. Do not chat_send to an agrn-* session id. Do not spawn task_create here unless the terms require it after the greeting. Do not re-negotiate — the agreement is already active.",
181
- },
182
- actions: {
183
- ...dflt.actions,
184
- openChat: {
185
- prompt: {
186
- when: "Call chat_open with the buyer and the claimed agreementId. The server usually already made the hire's room at claim; this returns it, or opens one when there is none.",
187
- instruction: "Open or reuse the hire's room with the buyer who claimed the listing.",
188
- format: '{"participantId": "<buyer actorId / Sender from the event>", "agreementId": "<the claimed agreement id>"}',
189
- },
190
- tool: 'chat_open',
191
- produces: (result) => {
192
- const r = result;
193
- return r?.chatId ? 'tool-result' : 'error';
194
- },
195
- /**
196
- * Deliberately does NOT terminate the turn.
197
- *
198
- * It used to, with "stay in this state so the next turn can greet in the
199
- * opened chat" — but the next turn re-enters this state from scratch,
200
- * reads the same prompt ("there is not yet a real chat"), and opens the
201
- * chat again. Nothing in a re-entered state says a chat is already open:
202
- * `chat_open` is idempotent, so every retry succeeds, produces
203
- * `tool-result`, and re-enters. Measured: three lab workers called
204
- * `chat_open` twelve times each, sent zero messages, and tripped the
205
- * depth guard — the greeting never happened and the wake was spent.
206
- *
207
- * Letting the turn continue puts the chatId in front of the model as a
208
- * tool result, so `greetBuyer` runs in the SAME turn. `message_sent`
209
- * outranks `tool_result` when the outcome is picked, so the turn leaves
210
- * on `message-sent` → initial: one turn, one greeting, no re-entry.
211
- */
212
- },
213
- greetBuyer: {
214
- prompt: {
215
- when: 'A chat with the buyer is open (chat_open just returned a chatId, or one already exists).',
216
- instruction: 'Greet the buyer and acknowledge you will serve the claimed agreement terms.',
217
- format: '{"receiverId": "<buyer id>", "message": "<short greeting>", "chatId": "<chatId from chat_open when available>"}',
218
- },
219
- produces: 'message-sent',
220
- terminatesLoop: true,
221
- },
222
- },
223
- transitions: [
224
- // After chat_open, re-enter so greetBuyer can run in the same kickoff.
225
- { to: CLAIM_KICKOFF_STATE, when: o => o.kind === 'tool-result' },
226
- { to: initial, when: o => o.kind === 'message-sent' },
227
- ...dflt.transitions,
228
- ],
229
- };
230
- }
231
104
  /**
232
- * Agreement-kickoff defaults. For every state whose transitions do not already
233
- * handle the corresponding probe, add a transition to the injected kickoff
234
- * state. Returns a NEW workflow — input states are not mutated.
235
- *
236
- * Two kickoffs:
237
- * - `kickingOff` — proposer-side approval → task_create
238
- * - `claimKickingOff` — store-claim provider wake → chat_open + greet
239
- * Direct-proposal counterparties (lifecycle approve, !proposedByMe, no
240
- * fromClaimWake) still never auto-kickoff.
105
+ * Agreement-kickoff default. For every state whose transitions do not already
106
+ * handle a proposer-side approval, append a transition to the injected
107
+ * `kickingOff` state, which spawns the first task. Appended LAST: the approval
108
+ * is of a proposal THIS agent made, so a route its author wrote keeps winning.
109
+ * Returns a NEW workflow; input states are not mutated.
241
110
  *
242
- * **They are ordered differently on purpose** (first match wins in
243
- * AgentMachine._evaluateTransitions):
244
- *
245
- * - `kickingOff` is appended LAST. It fires on an approval of a proposal THIS
246
- * agent made, which the author was already expecting — whatever route they
247
- * wrote for it should keep winning.
248
- * - `claimKickingOff` is inserted FIRST. It fires on a wake the author never
249
- * wrote a route for, and it has to beat the unconditional fallthrough an
250
- * `idle` state uses to reach its listening loop — otherwise the claim is
251
- * swallowed into the idle loop and the buyer is never greeted. Going first
252
- * is safe because its `when` is narrow: only a `fromClaimWake` approve
253
- * matches, so nothing else is shadowed. An author who wants the claim for
254
- * themselves keys on `fromClaimWake`, which suppresses the injection
255
- * outright (stateHandlesClaimKickoff).
111
+ * Counterparties never auto-kickoff, and neither does a store claim: a claim
112
+ * opens no room, and the buyer's first message or task is what wakes the
113
+ * provider.
256
114
  */
257
115
  export function injectKickoffDefault(workflow) {
258
116
  if (workflow.states[KICKOFF_STATE]) {
259
117
  throw new Error(`[defineAgent] state name "${KICKOFF_STATE}" is reserved for the injected agreement-kickoff default — rename the state`);
260
118
  }
261
- if (workflow.states[CLAIM_KICKOFF_STATE]) {
262
- throw new Error(`[defineAgent] state name "${CLAIM_KICKOFF_STATE}" is reserved for the injected claim-kickoff default — rename the state`);
263
- }
264
119
  const states = {};
265
- let injectedProposer = false;
266
- let injectedClaim = false;
120
+ let injected = false;
267
121
  for (const [name, state] of Object.entries(workflow.states)) {
268
- const transitions = [...state.transitions];
269
- if (!stateHandlesProposerApproval(state)) {
270
- transitions.push(KICKOFF_TRANSITION);
271
- injectedProposer = true;
272
- }
273
- if (!stateHandlesClaimKickoff(state)) {
274
- transitions.unshift(CLAIM_KICKOFF_TRANSITION);
275
- injectedClaim = true;
122
+ if (stateHandlesProposerApproval(state)) {
123
+ states[name] = state;
124
+ continue;
276
125
  }
277
- states[name] =
278
- transitions.length === state.transitions.length
279
- ? state
280
- : { ...state, transitions };
126
+ states[name] = { ...state, transitions: [...state.transitions, KICKOFF_TRANSITION] };
127
+ injected = true;
281
128
  }
282
- if (!injectedProposer && !injectedClaim)
129
+ if (!injected)
283
130
  return workflow;
284
- if (injectedProposer)
285
- states[KICKOFF_STATE] = buildKickoffState(workflow.initial);
286
- if (injectedClaim)
287
- states[CLAIM_KICKOFF_STATE] = buildClaimKickoffState(workflow.initial);
131
+ states[KICKOFF_STATE] = buildKickoffState(workflow.initial);
288
132
  return { ...workflow, states };
289
133
  }
@@ -0,0 +1,9 @@
1
+ import type { MessageCommand } from '@ziggs-ai/contracts';
2
+ import type { EffectHandler } from '../types.js';
3
+ /** Compose an FSM response from a fresh, exact trigger. A session locates the
4
+ * wake context; it is never a room, principal or authority identifier. */
5
+ export declare function responseCommand(eff: EffectHandler, sessionId: string, text: string, options?: {
6
+ operationId?: string;
7
+ recipientId?: string;
8
+ answer?: boolean;
9
+ }): Promise<MessageCommand>;
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /** Compose an FSM response from a fresh, exact trigger. A session locates the
3
+ * wake context; it is never a room, principal or authority identifier. */
4
+ export async function responseCommand(eff, sessionId, text, options = {}) {
5
+ const snapshot = await eff({ kind: 'read-context', sessionId, opts: { includeHistory: false } });
6
+ if (snapshot.unavailable)
7
+ throw new Error('Required response context is unavailable');
8
+ const parent = snapshot.messageRecord;
9
+ const recipient = options.recipientId ?? (parent && !parent.sender.isSelf ? parent.sender.ref : undefined);
10
+ if (!recipient)
11
+ throw new Error('Name an explicit recipient or read an exact message before replying');
12
+ const slots = parent?.requestedAnswers.filter(r => r.responsible.isSelf) ?? [];
13
+ if (options.answer && slots.length > 1)
14
+ throw new Error('Select the exact requested-answer slot with message_send');
15
+ const answer = options.answer && slots.length === 1 && recipient === parent?.sender.ref ? slots[0].action : undefined;
16
+ return {
17
+ operationId: options.operationId ?? randomUUID(), content: { text },
18
+ audience: { kind: 'explicit', principalIds: [recipient] }, deliveries: { kind: 'explicit', principalIds: [recipient] },
19
+ effect: answer ? { kind: 'answer', action: answer, artifacts: [] } : { kind: 'none' },
20
+ ...(snapshot.messageMandate ? { mandate: snapshot.messageMandate } : {}),
21
+ ...(parent ? { relationships: { replyToMessageId: parent.messageId } } : {}),
22
+ };
23
+ }
@@ -0,0 +1,6 @@
1
+ /** A failed required read is retryable work, never a successfully handled wake. */
2
+ export declare class RequiredContextUnavailable extends Error {
3
+ readonly code = "REQUIRED_CONTEXT_UNAVAILABLE";
4
+ constructor();
5
+ }
6
+ export declare function assertRequiredContext(snapshot: unknown): void;
@@ -0,0 +1,23 @@
1
+ /** A failed required read is retryable work, never a successfully handled wake. */
2
+ export class RequiredContextUnavailable extends Error {
3
+ code = 'REQUIRED_CONTEXT_UNAVAILABLE';
4
+ constructor() {
5
+ super('Required context is unavailable. Leave the responsibility pending until access or the read recovers.');
6
+ this.name = 'RequiredContextUnavailable';
7
+ }
8
+ }
9
+ export function assertRequiredContext(snapshot) {
10
+ const record = (value) => value && typeof value === 'object' ? value : {};
11
+ const root = record(snapshot);
12
+ const unavailable = (work) => {
13
+ const packet = record(work);
14
+ return packet.unavailable === true
15
+ || (Array.isArray(packet.contexts) && packet.contexts.some(unavailable));
16
+ };
17
+ // minimumComplete=false can mean paginated/truncated input awaiting expansion.
18
+ // authorization.active=false can be a readable proposal awaiting a decision.
19
+ // Those packets still support recovery; operation/task policy gates new work.
20
+ if (snapshot == null || root.unavailable === true || unavailable(root.work)) {
21
+ throw new RequiredContextUnavailable();
22
+ }
23
+ }
@@ -1,5 +1,6 @@
1
+ import { responseCommand } from './messageCommand.js';
1
2
  import { permitsAutomaticMandate } from './agreementContext.js';
2
- import { isAgreementId } from '@ziggs-ai/api-client';
3
+ import { assertRequiredContext } from './requiredContext.js';
3
4
  import { outcomeFromActionResult } from './buildOutcome.js';
4
5
  import { runtimeLog } from '../shared/runtimeLog.js';
5
6
  import { safeParseJSON } from '../utils/jsonExtractor.js';
@@ -57,10 +58,6 @@ export async function runTurn(input) {
57
58
  const runId = crypto.randomUUID();
58
59
  // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects,
59
60
  // and the wake's replay-stable write keys onto tool-call effects.
60
- // Send-message effects without an explicit messageId get a wake-derived one
61
- // here — a crash-replay of the wake re-derives the same id and the backend's
62
- // (chatId, messageId) dedup drops the duplicate. Undefined on
63
- // keyless wakes, where the transport generates a fresh id as before.
64
61
  const effWithTrace = (effect) => {
65
62
  if (effect.kind === 'llm-call') {
66
63
  return eff({ ...effect, runId, stateId });
@@ -68,16 +65,12 @@ export async function runTurn(input) {
68
65
  if (effect.kind === 'tool-call') {
69
66
  return eff({ ...effect, runId, stateId, writeKeys });
70
67
  }
71
- if (effect.kind === 'send-message' && !effect.messageId) {
68
+ if (effect.kind === 'send-message' && writeKeys) {
72
69
  const send = effect;
73
- return eff({
74
- ...effect,
75
- messageId: writeKeys?.messageKey({
76
- text: send.text,
77
- sessionId: send.sessionId,
78
- receiverId: send.receiverId,
79
- }),
80
- });
70
+ const { operationId: _operationId, ...payload } = send.command;
71
+ const operationId = writeKeys.messageKey({ text: JSON.stringify(payload), sessionId: send.sessionId });
72
+ if (operationId)
73
+ return eff({ ...send, command: { ...send.command, operationId } });
81
74
  }
82
75
  return eff(effect);
83
76
  };
@@ -112,39 +105,13 @@ export async function runTurn(input) {
112
105
  })
113
106
  : [];
114
107
  const serverContext = await effWithTrace({ kind: 'read-context', sessionId, opts: {} });
108
+ assertRequiredContext(serverContext);
115
109
  const ctxObj = normalizeContextSnapshot(serverContext);
116
- // A wake whose chat no longer resolves (deleted, or never visible to this
117
- // agent) has no world to decide over — deciding anyway means reasoning
118
- // about a fabricated empty history plus whatever stale scraps other
119
- // fetches returned. Skip the LLM and park. Resource wakes used to slip
120
- // past a message-only gate and burn a full turn. A bare
121
- // agreement id as sessionId is a mis-keyed lane — loud-log the empty
122
- // snapshot rather than staying silent.
123
- if (ctxObj.chatMissing) {
124
- const looksLikeBareAgreement = isAgreementId(sessionId);
125
- if (looksLikeBareAgreement) {
126
- runtimeLog.warn('runTurn', `chatMissing on non-chat sessionId=${sessionId} (state=${stateId}) — ` +
127
- `lane was keyed by agreementId instead of the agreement's origin/notification chat; ` +
128
- `wake continues with an empty snapshot`);
129
- }
130
- else {
131
- // Any wake in a room this agent cannot read — not only
132
- // message wakes. Resource notices used to spend a full turn here.
133
- runtimeLog.warn('runTurn', `chat ${sessionId} not readable — skipping wake (state=${stateId})`);
134
- return {
135
- outcome: { kind: 'wait' },
136
- toolResults: [],
137
- llmCalls: 0,
138
- tokens: { total: 0, input: 0, output: 0 },
139
- skippedReason: `chat_unreadable:${sessionId}`,
140
- };
141
- }
142
- }
143
110
  const prompt = promptBuilder.buildFromState({
144
111
  statePrompt, actions,
145
112
  serverContext: { context: ctxObj, tools: tools },
146
113
  incomingEvent, definition,
147
- chatId: sessionId, stateId, ctx,
114
+ addressId: sessionId, stateId, ctx,
148
115
  omitMessageFromRespond: streamRespondActions,
149
116
  });
150
117
  const systemContent = tools.length > 0
@@ -400,24 +367,15 @@ export async function runTurn(input) {
400
367
  sessionId,
401
368
  receiverId: decision.receiverId,
402
369
  }) ?? chunkMessageId;
403
- await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: persistMessageId });
404
- allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
370
+ await eff({ kind: 'send-message', sessionId, command: await responseCommand(eff, sessionId, safeText, { recipientId: decision.receiverId, operationId: persistMessageId, answer: true }) });
371
+ allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, addressId: sessionId, message: safeText, senderId: 'system' });
405
372
  lastAction = chosenAction;
406
373
  lastActionDef = actionDef;
407
374
  }
408
375
  catch (err) {
409
- if (isReplySlotTaken(err)) {
410
- runtimeLog.warn('runTurn', `reply already persisted under ${persistMessageId} (idempotency 409) — a replay or second host answered this wake first; dropping duplicate`);
411
- allEmittedEvents.push({ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: sessionId, senderId: 'system' });
412
- lastAction = chosenAction;
413
- lastActionDef = actionDef;
414
- }
415
- else {
416
- // Both the streaming path AND the non-streaming fallback failed —
417
- // the user got nothing. End the turn with a loud send-failed error.
418
- runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
419
- turnError = { source: 'protocol', cause: 'send-failed' };
420
- }
376
+ // A conflict is a refusal, never proof that this answer was delivered.
377
+ runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
378
+ turnError = { source: 'protocol', cause: 'send-failed' };
421
379
  }
422
380
  break;
423
381
  }
@@ -671,47 +629,17 @@ function truncateMsg(msg) {
671
629
  return msg;
672
630
  return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
673
631
  }
674
- /**
675
- * A send rejected with 409 hit the backend's (chatId, messageId) idempotency
676
- * gate on a wake-derived id: this wake's reply already exists. Two legal ways
677
- * that happens — a crash-replay re-ran the wake, or a second host raced the
678
- * same at-least-once delivery — and in both the re-run LLM produced different
679
- * text, so the backend rejects instead of no-op'ing (treats
680
- * same-key-same-content as replay, same-key-different-content as conflict).
681
- * The user already has the winner's reply. Re-sending under a fresh id would
682
- * double-message; failing the turn marks an answered wake as an error. Treat
683
- * it as delivered-by-the-other-attempt and move on.
684
- */
685
- function isReplySlotTaken(err) {
686
- return err?.status === 409;
687
- }
688
632
  async function executeSendMessage(decision, eff, sessionId) {
689
- // Callers guarantee message text. receiverId is set only when the model
690
- // named someone; an answer without one is sent without one. sessionId is
691
- // always ctx.identity.sessionId. A send failure throws to the turn loop,
692
- // which converts it into the 'send-failed' error outcome — no silent nulls,
693
- // and message_sent is only emitted after the send effect resolved.
694
- // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
695
- // for cross-session sends (e.g. reporting to a parent task chat), fall back
696
- // to the current session otherwise.
697
- const targetSessionId = decision.chatId || sessionId;
633
+ if (decision.addressId && decision.addressId !== sessionId)
634
+ throw new Error('A response cannot route through a room. Use message_send with explicit recipients and mandate.');
635
+ const targetSessionId = sessionId;
698
636
  const safeMessage = truncateMsg(decision.message);
699
637
  const receiverId = decision.receiverId || undefined;
700
- try {
701
- await eff({
702
- kind: 'send-message',
703
- sessionId: targetSessionId,
704
- ...(receiverId ? { receiverId } : {}),
705
- text: safeMessage,
706
- });
707
- }
708
- catch (err) {
709
- if (!isReplySlotTaken(err))
710
- throw err;
711
- runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — a replay or second host answered first; dropping duplicate${receiverId ? ` to ${receiverId}` : ''}`);
712
- return [{ type: 'message_duplicate_skipped', ...(receiverId ? { receiverId } : {}), chatId: targetSessionId, senderId: 'system' }];
713
- }
714
- const events = [{ type: 'message_sent', ...(receiverId ? { receiverId } : {}), chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
638
+ await eff({
639
+ kind: 'send-message', sessionId,
640
+ command: await responseCommand(eff, sessionId, safeMessage, { recipientId: receiverId, answer: true }),
641
+ });
642
+ const events = [{ type: 'message_sent', ...(receiverId ? { receiverId } : {}), addressId: targetSessionId, message: safeMessage, senderId: 'system' }];
715
643
  if (targetSessionId !== sessionId)
716
644
  events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
717
645
  return events;
@@ -734,29 +662,26 @@ async function executeSendMessages(decision, eff, sessionId) {
734
662
  failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing message' });
735
663
  continue;
736
664
  }
737
- const targetSessionId = msg.chatId || sessionId;
665
+ if (msg.addressId && msg.addressId !== sessionId) {
666
+ failures.push({ receiverId: msg.receiverId, error: 'Use message_send with explicit recipients and mandate for a separate destination' });
667
+ continue;
668
+ }
669
+ const targetSessionId = sessionId;
738
670
  const safeMessage = truncateMsg(msg.message);
739
671
  attempted++;
740
672
  try {
741
673
  await eff({
742
674
  kind: 'send-message',
743
- sessionId: targetSessionId,
744
- ...(msg.receiverId ? { receiverId: msg.receiverId } : {}),
745
- text: safeMessage,
675
+ sessionId, command: await responseCommand(eff, sessionId, safeMessage, { recipientId: msg.receiverId, answer: true }),
746
676
  });
747
677
  }
748
678
  catch (err) {
749
- if (isReplySlotTaken(err)) {
750
- runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — dropping duplicate to ${msg.receiverId}`);
751
- events.push({ type: 'message_duplicate_skipped', receiverId: msg.receiverId, chatId: targetSessionId, senderId: 'system' });
752
- continue;
753
- }
754
679
  // Scenario: backend rejected or transport dropped this one send. Record
755
680
  // it and keep going — remaining recipients must still get their message.
756
681
  failures.push({ receiverId: msg.receiverId, error: err.message });
757
682
  continue;
758
683
  }
759
- events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' });
684
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, addressId: targetSessionId, message: safeMessage, senderId: 'system' });
760
685
  if (targetSessionId !== sessionId)
761
686
  events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
762
687
  }
@@ -842,12 +767,12 @@ async function echoOptInToolSummaryIfNeeded({ allEmittedEvents, eff, sessionId,
842
767
  const safe = truncateMsg(summary);
843
768
  const wasTruncated = safe !== summary;
844
769
  try {
845
- await eff({ kind: 'send-message', sessionId, receiverId, text: safe });
770
+ await eff({ kind: 'send-message', sessionId, command: await responseCommand(eff, sessionId, safe, { recipientId: receiverId }) });
846
771
  }
847
772
  catch {
848
773
  return;
849
774
  }
850
- const event = { type: 'message_sent', receiverId, chatId: sessionId, message: safe, senderId: 'system' };
775
+ const event = { type: 'message_sent', receiverId, addressId: sessionId, message: safe, senderId: 'system' };
851
776
  if (wasTruncated) {
852
777
  event['truncated'] = true;
853
778
  event['originalLength'] = summary.length;
@@ -1,5 +1,5 @@
1
1
  import { thinkingDefaults } from './defaults.js';
2
- import { CLAIM_KICKOFF_STATE, KICKOFF_STATE } from './kickoff.js';
2
+ import { KICKOFF_STATE } from './kickoff.js';
3
3
  import { PROBE_CTX } from './validateWorkflow.js';
4
4
  /** Reserved state name for the injected assigned-task-intake default. */
5
5
  export const TASK_INTAKE_STATE = 'workingAssignedTask';
@@ -35,15 +35,17 @@ export function buildTaskIntakeState(initial) {
35
35
  prompt: {
36
36
  role: 'A task was just assigned to you under one of your active agreements, and your workflow has no explicit route for it. This injected default exists so an assigned task is NEVER silently dropped — under a standing hire, new tasks keep arriving for the lifetime of the agreement.',
37
37
  goal: 'Resolve the assigned task this turn: deliver the result if you can produce it, otherwise fail it with a reason.',
38
- constraints: 'The task is in <event> (and as activeTaskId / activeAgreementId in <machine_context>). If the deliverable can be produced in this turn (an answer, summary, plan, draft — most representation work), call task_set_result with state "completed" and the deliverable as result — the result is what the counterparty consumes, so make it the actual deliverable, not a status note. If the task genuinely needs abilities or tools you do not have, call task_set_result with state "failed" and a one-line reason — a visible failure the counterparty can re-route beats a silent drop. Never leave the task dangling or answer with a chat message alone.',
38
+ constraints: 'The task is in <event> (and as activeTaskId / activeAgreementId in <machine_context>). If the deliverable can be produced in this turn (an answer, summary, plan, draft — most representation work), call task_set_result with state "completed" and the deliverable as result.summary — the result is what the counterparty consumes, so make it the actual deliverable, not a status note. If the task genuinely needs abilities or tools you do not have, call task_set_result with state "failed" and a one-line reason as result.summary — a visible failure the counterparty can re-route beats a silent drop. Never leave the task dangling or answer with a chat message alone.',
39
39
  },
40
40
  actions: {
41
41
  ...dflt.actions,
42
- completeTask: {
42
+ // One action for both outcomes. Two actions bound to task_set_result
43
+ // left a native tool call ambiguous, so the runtime dropped it.
44
+ setTaskResult: {
43
45
  prompt: {
44
- when: 'You can produce the deliverable for the assigned task in this turn.',
45
- instruction: 'Complete the assigned task, with the deliverable itself as the result.',
46
- format: '{"taskId": "<assigned task id from <event>>", "state": "completed", "result": "<the full deliverable>"}',
46
+ when: 'You can produce the deliverable for the assigned task in this turn, or it needs abilities, tools or information you cannot obtain.',
47
+ instruction: 'Close the assigned task: state "completed" with the deliverable itself as the result, or state "failed" with a one-line reason.',
48
+ format: '{"taskId": "<assigned task id from <event>>", "state": "completed" | "failed", "result": {"summary": "<the full deliverable, or the one-line reason>"}}',
47
49
  },
48
50
  tool: 'task_set_result',
49
51
  // Function form so a failed task_set_result surfaces as an error outcome
@@ -56,21 +58,6 @@ export function buildTaskIntakeState(initial) {
56
58
  },
57
59
  terminatesLoop: true,
58
60
  },
59
- failTask: {
60
- prompt: {
61
- when: 'The task needs abilities, tools, or information you cannot obtain this turn.',
62
- instruction: 'Fail the assigned task with a one-line reason so the counterparty can re-route it.',
63
- format: '{"taskId": "<assigned task id from <event>>", "state": "failed", "result": "<one-line reason>"}',
64
- },
65
- tool: 'task_set_result',
66
- produces: (result) => {
67
- // `state` only cut `status` from the wire.
68
- const r = result;
69
- const state = r?.task?.state ?? r?.state;
70
- return state === 'completed' || state === 'failed' ? 'task-closed' : 'error';
71
- },
72
- terminatesLoop: true,
73
- },
74
61
  },
75
62
  transitions: [
76
63
  { to: initial, when: o => o.kind === 'task-closed' },
@@ -102,7 +89,6 @@ export function injectTaskIntakeDefault(workflow) {
102
89
  // a task-assigned route must not force the intake state into a workflow
103
90
  // whose author states all handle assignment.
104
91
  if (name === KICKOFF_STATE ||
105
- name === CLAIM_KICKOFF_STATE ||
106
92
  stateHandlesTaskAssigned(state)) {
107
93
  states[name] = state;
108
94
  continue;
@@ -15,26 +15,17 @@ export declare function contextScopesFromWake(event: unknown, sessionId: string)
15
15
  /** Per-wake observation, including effects nested inside tools and cross-room sends. */
16
16
  export declare class WakeEffects {
17
17
  private readonly initialScope;
18
- private readonly sessionId?;
19
18
  private readonly inferScope;
20
19
  sentMessage: boolean;
21
20
  taskWrite: boolean;
22
21
  readonly planVersions: Set<string>;
23
22
  readonly sentMessageKeys: Set<string>;
24
- /** Agreements the wake's OWN room reported, from its fenced snapshot. */
25
- private roomAgreementIds;
26
23
  /** Confirmed task writes: work this wake actually performed. */
27
24
  private taskWrites;
28
- constructor(initialScope: WorkScope, sessionId?: string | undefined, inferScope?: boolean);
25
+ constructor(initialScope: WorkScope, inferScope?: boolean);
29
26
  static forWake(event: unknown, sessionId: string): WakeEffects;
30
- /**
31
- * Structured wake fields first. Otherwise the room's one agreement is the
32
- * scope — spend follows who the agent acts for, and in a room that is the
33
- * room's agreement — and a confirmed task write names its task only when
34
- * the server tied the write to that same agreement. A plan written for
35
- * customer A from customer B's room stays billed to B and names no task; a
36
- * write whose agreement the server did not report adds nothing but the room.
37
- */
27
+ /** Structured work wins. A confirmed task write may identify work performed;
28
+ * reading a context snapshot never selects billing or execution authority. */
38
29
  get scope(): WorkScope;
39
30
  observe(effect: Effect, response: unknown): void;
40
31
  }