@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.
- package/CHANGELOG.md +40 -0
- package/README.md +1 -1
- package/dist/Agent.d.ts +27 -8
- package/dist/Agent.js +188 -98
- package/dist/brain/ClaudeBrain.d.ts +0 -1
- package/dist/brain/ClaudeBrain.js +18 -42
- package/dist/brain/claudeCognition.js +5 -6
- package/dist/brain/claudeSdkProtocol.js +9 -9
- package/dist/brain/claudeSdkTools.d.ts +5 -5
- package/dist/brain/claudeSdkTools.js +40 -135
- package/dist/brain/hostedToolSurface.js +1 -1
- package/dist/brain/standingOfferNotice.d.ts +0 -2
- package/dist/brain/standingOfferNotice.js +1 -21
- package/dist/brain/toolManagerToSdkMcp.js +1 -1
- package/dist/brain/wakeGate.d.ts +3 -1
- package/dist/brain/wakeGate.js +6 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/ingress/normalizeIncoming.d.ts +5 -3
- package/dist/ingress/normalizeIncoming.js +9 -8
- package/dist/mcp/createMcpAgent.d.ts +15 -0
- package/dist/mcp/createMcpAgent.js +47 -11
- package/dist/memory/MemoryStore.d.ts +1 -1
- package/dist/memory/MemoryStore.js +1 -6
- package/dist/platform/ConnectionPool.js +1 -1
- package/dist/platform/OutboxBuffer.d.ts +2 -2
- package/dist/platform/OutboxBuffer.js +17 -17
- package/dist/platform/StateTimers.d.ts +3 -1
- package/dist/platform/StateTimers.js +4 -3
- package/dist/platform/WakeLedger.d.ts +10 -6
- package/dist/platform/WakeLedger.js +16 -9
- package/dist/platform/ZiggsEffectHandler.d.ts +18 -4
- package/dist/platform/ZiggsEffectHandler.js +86 -55
- package/dist/platform/agreements/AgreementService.d.ts +1 -3
- package/dist/platform/agreements/AgreementService.js +1 -7
- package/dist/platform/inboxLoop.d.ts +5 -9
- package/dist/platform/inboxLoop.js +165 -158
- package/dist/platform/polls/PollKind.d.ts +13 -16
- package/dist/platform/polls/PollKind.js +4 -4
- package/dist/platform/polls/PollRunner.d.ts +3 -20
- package/dist/platform/polls/PollRunner.js +13 -15
- package/dist/platform/polls/declaredPollRunner.d.ts +1 -1
- package/dist/platform/polls/declaredPollRunner.js +1 -1
- package/dist/platform/tasks/TaskService.d.ts +4 -3
- package/dist/platform/tasks/TaskService.js +1 -4
- package/dist/platform/telemetryIngest.js +4 -4
- package/dist/platform/tools/index.d.ts +1 -2
- package/dist/platform/tools/index.js +1 -4
- package/dist/platform/tools/tier1/protocolRunner.d.ts +1 -1
- package/dist/platform/tools/tier1/protocolRunner.js +24 -17
- package/dist/platform/tools/tier1/protocolTools.js +2 -2
- package/dist/platform/tools/tier1/wakeTools.d.ts +1 -1
- package/dist/platform/tools/tier1/wakeTools.js +2 -1
- package/dist/platform/tools/tier2/mcpConnectionTools.js +1 -1
- package/dist/platform/tools/tier2/taskTools.d.ts +1 -0
- package/dist/platform/tools/tier2/taskTools.js +2 -1
- package/dist/runtime/AgentMachine.d.ts +2 -2
- package/dist/runtime/AgentMachine.js +4 -5
- package/dist/runtime/PromptBuilder.d.ts +1 -1
- package/dist/runtime/PromptBuilder.js +5 -4
- package/dist/runtime/buildOutcome.d.ts +1 -1
- package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
- package/dist/runtime/deliveryWriteKeys.js +1 -1
- package/dist/runtime/kickoff.d.ts +14 -43
- package/dist/runtime/kickoff.js +22 -178
- package/dist/runtime/messageCommand.d.ts +9 -0
- package/dist/runtime/messageCommand.js +23 -0
- package/dist/runtime/requiredContext.d.ts +6 -0
- package/dist/runtime/requiredContext.js +23 -0
- package/dist/runtime/runTurn.js +31 -106
- package/dist/runtime/taskIntake.js +8 -22
- package/dist/runtime/wakeEffects.d.ts +3 -12
- package/dist/runtime/wakeEffects.js +17 -48
- package/dist/runtime/wakeOutcome.js +1 -1
- package/dist/skills/createSkillAgent.js +12 -1
- package/dist/tools/capabilityAdapter.js +1 -1
- package/dist/tools/recordReport.d.ts +1 -1
- package/dist/tools/recordReport.js +3 -3
- package/dist/tools/toolCreds.d.ts +1 -1
- package/dist/tools/toolCreds.js +4 -2
- package/dist/types.d.ts +17 -27
- package/package.json +3 -3
- package/dist/platform/tools/tier2/chatTools.d.ts +0 -3
- package/dist/platform/tools/tier2/chatTools.js +0 -7
- package/dist/probeChat.d.ts +0 -10
- package/dist/probeChat.js +0 -12
package/dist/runtime/kickoff.js
CHANGED
|
@@ -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
|
-
*
|
|
148
|
-
*
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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
|
|
233
|
-
* handle
|
|
234
|
-
* state
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
-
*
|
|
243
|
-
*
|
|
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
|
|
266
|
-
let injectedClaim = false;
|
|
120
|
+
let injected = false;
|
|
267
121
|
for (const [name, state] of Object.entries(workflow.states)) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
279
|
-
? state
|
|
280
|
-
: { ...state, transitions };
|
|
126
|
+
states[name] = { ...state, transitions: [...state.transitions, KICKOFF_TRANSITION] };
|
|
127
|
+
injected = true;
|
|
281
128
|
}
|
|
282
|
-
if (!
|
|
129
|
+
if (!injected)
|
|
283
130
|
return workflow;
|
|
284
|
-
|
|
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
|
+
}
|
package/dist/runtime/runTurn.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { responseCommand } from './messageCommand.js';
|
|
1
2
|
import { permitsAutomaticMandate } from './agreementContext.js';
|
|
2
|
-
import {
|
|
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' &&
|
|
68
|
+
if (effect.kind === 'send-message' && writeKeys) {
|
|
72
69
|
const send = effect;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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,
|
|
404
|
-
allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId,
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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: '
|
|
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,
|
|
25
|
+
constructor(initialScope: WorkScope, inferScope?: boolean);
|
|
29
26
|
static forWake(event: unknown, sessionId: string): WakeEffects;
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
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
|
}
|