@deepseek-ai/dsh-action-cards 0.0.0-stage → 0.2.2-alpha.2
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/LICENSE +21 -0
- package/README.i18n.yaml +67 -0
- package/README.md +136 -2
- package/README.zh.md +137 -0
- package/lib/index.js +625 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +873 -0
- package/lib/typert.remote-client.d.ts +31 -0
- package/lib/typert.remote-client.js +137 -0
- package/lib/types/index.d.ts +178 -0
- package/lib/types/index.js +398 -0
- package/lib/types/projection.d.ts +66 -0
- package/lib/types/projection.js +122 -0
- package/lib/types/types.d.ts +81 -0
- package/lib/types/types.js +2 -0
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/pt.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
+
import { HarnessError, ToolCallId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
6
|
+
import { SessionLogOffset, SessionSeq, appendPluginRecord, pluginRecordOf } from "@deepseek-ai/dsh-session";
|
|
7
|
+
//#region lib/types/projection.js
|
|
8
|
+
/** Recover card proposals from logged `propose_action` calls and decisions from plugin records. */
|
|
9
|
+
/** Model-facing name of the card tool. */
|
|
10
|
+
const PROPOSE_ACTION = "propose_action";
|
|
11
|
+
/** Kind-defined JSON object of a proposal or decision. */
|
|
12
|
+
const fieldsSchema = z.record(z.string(), z.json());
|
|
13
|
+
/** Arguments of a `propose_action` call; blank titles and briefs create no card. */
|
|
14
|
+
const argumentsSchema = z.object({
|
|
15
|
+
action: z.string().min(1),
|
|
16
|
+
title: z.string().trim().min(1),
|
|
17
|
+
brief: z.string().trim().min(1),
|
|
18
|
+
details: fieldsSchema
|
|
19
|
+
});
|
|
20
|
+
const proposalSchema = z.object({
|
|
21
|
+
callId: z.string().min(1).transform(ToolCallId),
|
|
22
|
+
turn: z.number().int().nonnegative(),
|
|
23
|
+
action: z.string().min(1),
|
|
24
|
+
title: z.string().min(1),
|
|
25
|
+
brief: z.string().min(1),
|
|
26
|
+
details: fieldsSchema
|
|
27
|
+
}).strict();
|
|
28
|
+
/** Recorded decision of one card. */
|
|
29
|
+
const decisionSchema = z.object({
|
|
30
|
+
callId: z.string().min(1).transform(ToolCallId),
|
|
31
|
+
action: z.string().min(1),
|
|
32
|
+
phase: z.string().min(1),
|
|
33
|
+
data: fieldsSchema.optional()
|
|
34
|
+
}).strict();
|
|
35
|
+
const viewSchema = z.object({
|
|
36
|
+
proposals: z.record(z.string(), proposalSchema),
|
|
37
|
+
decisions: z.record(z.string(), decisionSchema)
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Full-Session fold. A successful `propose_action` result admits the call as a card, at most one per turn;
|
|
41
|
+
* the first admitted call of a turn wins. A newer card of any kind replaces every unstarted earlier card.
|
|
42
|
+
* Events inherited by a forked Session create no proposals or decisions.
|
|
43
|
+
*/
|
|
44
|
+
const actionCardProjection = {
|
|
45
|
+
key: "actionCards",
|
|
46
|
+
stateVersion: 2,
|
|
47
|
+
stateSchema: z.object({
|
|
48
|
+
/** Fork-inherited prefix length; inherited events create no proposals or decisions. */
|
|
49
|
+
inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset),
|
|
50
|
+
view: viewSchema,
|
|
51
|
+
/** `propose_action` calls of the open turn still waiting for their result. */
|
|
52
|
+
calls: z.record(z.string(), proposalSchema.omit({ callId: true })),
|
|
53
|
+
/** Turn of the latest admitted proposal; later calls of that turn create no card. */
|
|
54
|
+
admittedTurn: z.number().int().nullable(),
|
|
55
|
+
/** Decisions not yet reported to the model, by call id, with the seq of their record. */
|
|
56
|
+
pendingStatus: z.record(z.string(), z.number()),
|
|
57
|
+
/** User messages still queued for later turns, folded from inbox splices; the card tool compares it with its limit. */
|
|
58
|
+
queued: z.number().int().nonnegative()
|
|
59
|
+
}),
|
|
60
|
+
init: (_header, inheritedEventCount) => ({
|
|
61
|
+
inheritedEventCount,
|
|
62
|
+
view: {
|
|
63
|
+
proposals: {},
|
|
64
|
+
decisions: {}
|
|
65
|
+
},
|
|
66
|
+
calls: {},
|
|
67
|
+
admittedTurn: null,
|
|
68
|
+
pendingStatus: {},
|
|
69
|
+
queued: 0
|
|
70
|
+
}),
|
|
71
|
+
apply: (state, event) => {
|
|
72
|
+
if (event.seq < state.inheritedEventCount) return state;
|
|
73
|
+
switch (event.type) {
|
|
74
|
+
case "turn/start": return Object.keys(state.calls).length === 0 ? state : {
|
|
75
|
+
...state,
|
|
76
|
+
calls: {}
|
|
77
|
+
};
|
|
78
|
+
case "agent/inbox/spliced":
|
|
79
|
+
if (event.data.target !== "next-turn") return state;
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
queued: Math.max(0, state.queued - (event.data.removedCount ?? 0) + event.data.inserted.length)
|
|
83
|
+
};
|
|
84
|
+
case "user/message": {
|
|
85
|
+
if (event.data.source.kind !== "action-cards") return state;
|
|
86
|
+
const through = event.data.source.through;
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
pendingStatus: Object.fromEntries(Object.entries(state.pendingStatus).filter(([, seq]) => seq > through))
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
case "tool/call": {
|
|
93
|
+
if (event.data.name !== "propose_action") return state;
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
raw = JSON.parse(event.data.arguments);
|
|
97
|
+
} catch {
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
const parsed = argumentsSchema.safeParse(raw);
|
|
101
|
+
if (!parsed.success) return state;
|
|
102
|
+
return {
|
|
103
|
+
...state,
|
|
104
|
+
calls: {
|
|
105
|
+
...state.calls,
|
|
106
|
+
[event.data.callId]: {
|
|
107
|
+
turn: event.data.turn,
|
|
108
|
+
...parsed.data
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
case "tool/result": {
|
|
114
|
+
const callId = event.data.message.toolCallId;
|
|
115
|
+
const call = Object.hasOwn(state.calls, callId) ? state.calls[callId] : void 0;
|
|
116
|
+
if (call === void 0) return state;
|
|
117
|
+
const calls = Object.fromEntries(Object.entries(state.calls).filter(([id]) => id !== callId));
|
|
118
|
+
if (event.data.message.isError === true || state.admittedTurn === call.turn) return {
|
|
119
|
+
...state,
|
|
120
|
+
calls
|
|
121
|
+
};
|
|
122
|
+
const decisions = { ...state.view.decisions };
|
|
123
|
+
for (const earlier of Object.values(state.view.proposals)) if (decisions[earlier.callId] === void 0) decisions[earlier.callId] = {
|
|
124
|
+
callId: earlier.callId,
|
|
125
|
+
action: earlier.action,
|
|
126
|
+
phase: "replaced"
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
...state,
|
|
130
|
+
calls,
|
|
131
|
+
admittedTurn: call.turn,
|
|
132
|
+
view: {
|
|
133
|
+
proposals: {
|
|
134
|
+
...state.view.proposals,
|
|
135
|
+
[callId]: {
|
|
136
|
+
callId,
|
|
137
|
+
...call
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
decisions
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
default: {
|
|
145
|
+
const record = pluginRecordOf(event);
|
|
146
|
+
if (record?.type !== "plugin:action-cards/decision") return state;
|
|
147
|
+
const decision = decisionSchema.safeParse(record.data);
|
|
148
|
+
if (!decision.success) return state;
|
|
149
|
+
return {
|
|
150
|
+
...state,
|
|
151
|
+
view: {
|
|
152
|
+
...state.view,
|
|
153
|
+
decisions: {
|
|
154
|
+
...state.view.decisions,
|
|
155
|
+
[decision.data.callId]: decision.data
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
pendingStatus: {
|
|
159
|
+
...state.pendingStatus,
|
|
160
|
+
[decision.data.callId]: event.seq
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
wire: {
|
|
167
|
+
summary: false,
|
|
168
|
+
viewSchema,
|
|
169
|
+
view: (state) => state.view
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region lib/types/index.js
|
|
174
|
+
/** Optional action cards below an answer: the `propose_action` tool, card state, user actions and status notices for registered kinds. */
|
|
175
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
176
|
+
var useValue = arguments.length > 2;
|
|
177
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
178
|
+
return useValue ? value : void 0;
|
|
179
|
+
};
|
|
180
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
181
|
+
function accept(f) {
|
|
182
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
183
|
+
return f;
|
|
184
|
+
}
|
|
185
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
186
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
187
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
188
|
+
var _, done = false;
|
|
189
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
190
|
+
var context = {};
|
|
191
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
192
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
193
|
+
context.addInitializer = function(f) {
|
|
194
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
195
|
+
extraInitializers.push(accept(f || null));
|
|
196
|
+
};
|
|
197
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
198
|
+
get: descriptor.get,
|
|
199
|
+
set: descriptor.set
|
|
200
|
+
} : descriptor[key], context);
|
|
201
|
+
if (kind === "accessor") {
|
|
202
|
+
if (result === void 0) continue;
|
|
203
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
204
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
205
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
206
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
207
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
208
|
+
else descriptor[key] = _;
|
|
209
|
+
}
|
|
210
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
211
|
+
done = true;
|
|
212
|
+
};
|
|
213
|
+
/** Deployment-tunable limits for offering cards. */
|
|
214
|
+
const Config = z.object({
|
|
215
|
+
/** Queued next-turn user messages at or above which `propose_action` refuses, so no card is offered. */
|
|
216
|
+
queuedMessageLimit: z.number().int().min(1).default(2) }).prefault({});
|
|
217
|
+
/** The user action every card offers; the shared service records it. */
|
|
218
|
+
const DISMISS = "dismiss";
|
|
219
|
+
/** Phases the projection derives from the log; no decision may record them. */
|
|
220
|
+
const DERIVED_PHASES = ["replaced"];
|
|
221
|
+
/** Phases after which no user action changes the card. */
|
|
222
|
+
const FINAL_PHASES = ["dismissed", ...DERIVED_PHASES];
|
|
223
|
+
/** Tool whose successful loads activate `propose_action`; its `name` argument names the skill. */
|
|
224
|
+
const SKILL_TOOL = "skill";
|
|
225
|
+
const skillArguments = z.object({ name: z.string() });
|
|
226
|
+
const STATUS_PREAMBLE = "Latest status of action cards from user actions and background work. These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress.";
|
|
227
|
+
const STATUS_CLOSING = "Continue with the user’s current request.";
|
|
228
|
+
/**
|
|
229
|
+
* Whether a plugin must refuse new work: its fiber or an ancestor is disposed or unloading, since a parent's disposal
|
|
230
|
+
* begins before the plugin's own effects run, or the plugin's own drain has begun.
|
|
231
|
+
* @param owner - the plugin's fiber.
|
|
232
|
+
* @param draining - whether the plugin's drain effect has started.
|
|
233
|
+
* @returns whether the plugin is stopping.
|
|
234
|
+
*/
|
|
235
|
+
function isPluginStopping(owner, draining) {
|
|
236
|
+
for (let fiber = owner;; fiber = fiber.parent.fiber) {
|
|
237
|
+
if (fiber.uid === null || fiber.state === 5) return true;
|
|
238
|
+
if (fiber.parent.fiber === fiber) return draining;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** A refused `propose_action` call; the message tells the model what to do instead. */
|
|
242
|
+
var ActionCardError = class extends HarnessError {};
|
|
243
|
+
/** Own entries only: browser-supplied ids such as `toString` never reach inherited object members. */
|
|
244
|
+
function ownEntry(record, key) {
|
|
245
|
+
return Object.hasOwn(record, key) ? record[key] : void 0;
|
|
246
|
+
}
|
|
247
|
+
function refused(reason) {
|
|
248
|
+
return {
|
|
249
|
+
status: "refused",
|
|
250
|
+
reason
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function issuesOf(error) {
|
|
254
|
+
return "Invalid details: " + error.issues.map((issue) => `${["details", ...issue.path.map(String)].join(".")}: ${issue.message}`).join("; ");
|
|
255
|
+
}
|
|
256
|
+
/** Card tool, kind registry, Session projection, user actions and status notices. */
|
|
257
|
+
let ActionCards = (() => {
|
|
258
|
+
let _classSuper = TypertRemoteService;
|
|
259
|
+
let _instanceExtraInitializers = [];
|
|
260
|
+
let _act_decorators;
|
|
261
|
+
return class ActionCards extends _classSuper {
|
|
262
|
+
static {
|
|
263
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
264
|
+
_act_decorators = [Remote];
|
|
265
|
+
__esDecorate(this, null, _act_decorators, {
|
|
266
|
+
kind: "method",
|
|
267
|
+
name: "act",
|
|
268
|
+
static: false,
|
|
269
|
+
private: false,
|
|
270
|
+
access: {
|
|
271
|
+
has: (obj) => "act" in obj,
|
|
272
|
+
get: (obj) => obj.act
|
|
273
|
+
},
|
|
274
|
+
metadata: _metadata
|
|
275
|
+
}, null, _instanceExtraInitializers);
|
|
276
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
277
|
+
enumerable: true,
|
|
278
|
+
configurable: true,
|
|
279
|
+
writable: true,
|
|
280
|
+
value: _metadata
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
284
|
+
static inject = [
|
|
285
|
+
"tools",
|
|
286
|
+
"agents",
|
|
287
|
+
"sessionProjections",
|
|
288
|
+
"remoteAccess"
|
|
289
|
+
];
|
|
290
|
+
static Config = Config;
|
|
291
|
+
projection;
|
|
292
|
+
kinds = /* @__PURE__ */ new Map();
|
|
293
|
+
/**
|
|
294
|
+
* Actions whose kind unregistered. Cordis disposes a plugin's effects concurrently, so a kind's unload drain can
|
|
295
|
+
* still record the outcome of its in-flight work after its registration is gone.
|
|
296
|
+
*/
|
|
297
|
+
retired = /* @__PURE__ */ new Set();
|
|
298
|
+
/** Skills of registered kinds each live agent has loaded; `propose_action` is declared while one is still registered. */
|
|
299
|
+
loaded = /* @__PURE__ */ new Map();
|
|
300
|
+
/** Kind skills each agent loaded in its latest turn. */
|
|
301
|
+
turnLoads = /* @__PURE__ */ new Map();
|
|
302
|
+
/** The call accepted in each agent's latest turn, until its result fails. */
|
|
303
|
+
accepted = /* @__PURE__ */ new Map();
|
|
304
|
+
actions = /* @__PURE__ */ new Map();
|
|
305
|
+
closing = false;
|
|
306
|
+
owner;
|
|
307
|
+
/**
|
|
308
|
+
* @param ctx - Owning plugin context.
|
|
309
|
+
* @param config - Validated card limits; direct composition without a config gets the schema defaults.
|
|
310
|
+
*/
|
|
311
|
+
constructor(ctx, config = Config.parse({})) {
|
|
312
|
+
super(ctx, "actionCards");
|
|
313
|
+
this.config = config;
|
|
314
|
+
this.owner = ctx.fiber;
|
|
315
|
+
}
|
|
316
|
+
async [Service.init]() {
|
|
317
|
+
this.projection = await this.ctx.sessionProjections.register(actionCardProjection);
|
|
318
|
+
this.ctx.tools.register(defineTool({
|
|
319
|
+
name: PROPOSE_ACTION,
|
|
320
|
+
description: "Show the user one optional action card under this answer. Call only as the skill that names the action instructs.",
|
|
321
|
+
parameters: {
|
|
322
|
+
action: {
|
|
323
|
+
type: "string",
|
|
324
|
+
required: true,
|
|
325
|
+
description: "Action name from the skill."
|
|
326
|
+
},
|
|
327
|
+
title: {
|
|
328
|
+
type: "string",
|
|
329
|
+
required: true,
|
|
330
|
+
description: "Card title."
|
|
331
|
+
},
|
|
332
|
+
brief: {
|
|
333
|
+
type: "string",
|
|
334
|
+
required: true,
|
|
335
|
+
description: "What the action does, in the user's language."
|
|
336
|
+
},
|
|
337
|
+
details: {
|
|
338
|
+
type: "object",
|
|
339
|
+
additionalProperties: true,
|
|
340
|
+
required: true,
|
|
341
|
+
description: "Fields the skill lists for this action."
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
output: {
|
|
345
|
+
schema: {
|
|
346
|
+
type: "object",
|
|
347
|
+
additionalProperties: false,
|
|
348
|
+
properties: {
|
|
349
|
+
action: {
|
|
350
|
+
type: "string",
|
|
351
|
+
required: true
|
|
352
|
+
},
|
|
353
|
+
title: {
|
|
354
|
+
type: "string",
|
|
355
|
+
required: true
|
|
356
|
+
},
|
|
357
|
+
invitation: {
|
|
358
|
+
type: "string",
|
|
359
|
+
required: true
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
render: (_args, value) => [{
|
|
364
|
+
type: "text",
|
|
365
|
+
text: `Card shown below your answer. Do not restate it. ${value.invitation}`
|
|
366
|
+
}]
|
|
367
|
+
},
|
|
368
|
+
execute: (args, exec) => Promise.resolve(this.propose(args, exec))
|
|
369
|
+
}), { when: ({ agent }) => this.declares(agent) });
|
|
370
|
+
this.ctx.on("tools/result", (exec, result) => {
|
|
371
|
+
const agent = exec.agent;
|
|
372
|
+
if (agent === void 0) return;
|
|
373
|
+
if (exec.name === "propose_action") {
|
|
374
|
+
if (result.isError && this.accepted.get(agent)?.callId === exec.callId) this.accepted.delete(agent);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (exec.parent !== void 0 || !this.ctx.agents.roots().includes(agent)) return;
|
|
378
|
+
const skill = exec.name === SKILL_TOOL && !result.isError ? skillArguments.safeParse(exec.arguments) : void 0;
|
|
379
|
+
if (skill?.success !== true || ![...this.kinds.values()].some((kind) => kind.skill === skill.data.name)) return;
|
|
380
|
+
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn;
|
|
381
|
+
const loads = this.turnLoads.get(agent);
|
|
382
|
+
if (loads?.turn === turn) loads.skills.add(skill.data.name);
|
|
383
|
+
else this.turnLoads.set(agent, {
|
|
384
|
+
turn,
|
|
385
|
+
skills: new Set([skill.data.name])
|
|
386
|
+
});
|
|
387
|
+
this.loaded.set(agent, new Set([...this.loaded.get(agent) ?? [], skill.data.name]));
|
|
388
|
+
});
|
|
389
|
+
this.ctx.on("agent/disposed", ({ agent }) => {
|
|
390
|
+
this.loaded.delete(agent);
|
|
391
|
+
this.turnLoads.delete(agent);
|
|
392
|
+
this.accepted.delete(agent);
|
|
393
|
+
});
|
|
394
|
+
this.ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
|
|
395
|
+
const decision = await next();
|
|
396
|
+
if (decision.kind === "reject" || signal.aborted || this.isStopping()) return decision;
|
|
397
|
+
const state = this.projection.stateOf(agent.session);
|
|
398
|
+
const pending = Object.values(state.pendingStatus);
|
|
399
|
+
if (pending.length === 0) return decision;
|
|
400
|
+
const receipts = Object.values(state.view.decisions).filter((receipt) => Object.hasOwn(state.pendingStatus, receipt.callId));
|
|
401
|
+
const guidance = new Set(receipts.flatMap((receipt) => this.kinds.get(receipt.action)?.statusGuidance ?? []));
|
|
402
|
+
const updates = receipts.map((receipt) => {
|
|
403
|
+
const fields = this.kinds.get(receipt.action)?.statusFields;
|
|
404
|
+
const data = receipt.data === void 0 || fields === void 0 ? receipt.data : Object.fromEntries(Object.entries(receipt.data).filter(([key]) => fields.includes(key)));
|
|
405
|
+
return {
|
|
406
|
+
action: receipt.action,
|
|
407
|
+
callId: receipt.callId,
|
|
408
|
+
title: ownEntry(state.view.proposals, receipt.callId)?.title,
|
|
409
|
+
phase: receipt.phase,
|
|
410
|
+
...data === void 0 ? {} : { data }
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
return {
|
|
414
|
+
...decision,
|
|
415
|
+
messages: [...decision.messages, createUserMessage({
|
|
416
|
+
source: {
|
|
417
|
+
kind: "action-cards",
|
|
418
|
+
through: SessionSeq(Math.max(...pending)),
|
|
419
|
+
form: "notice",
|
|
420
|
+
summary: "Action card status updated"
|
|
421
|
+
},
|
|
422
|
+
content: [{
|
|
423
|
+
type: "text",
|
|
424
|
+
text: [
|
|
425
|
+
STATUS_PREAMBLE,
|
|
426
|
+
...guidance,
|
|
427
|
+
STATUS_CLOSING
|
|
428
|
+
].join(" ") + "\n" + JSON.stringify(updates)
|
|
429
|
+
}]
|
|
430
|
+
})]
|
|
431
|
+
};
|
|
432
|
+
}, { prepend: true });
|
|
433
|
+
this.ctx.effect(() => async () => {
|
|
434
|
+
this.closing = true;
|
|
435
|
+
await Promise.allSettled([...this.actions.values()]);
|
|
436
|
+
}, "action-cards: drain user actions");
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Register one action kind for the caller's lifetime.
|
|
440
|
+
* @param kind - the kind's skill, details schema, verbs and handler.
|
|
441
|
+
* @returns disposer removing the kind; actions on its cards are then unavailable.
|
|
442
|
+
* @throws when another kind already registered the same action.
|
|
443
|
+
*/
|
|
444
|
+
register(kind) {
|
|
445
|
+
const typed = (proposal) => {
|
|
446
|
+
const details = kind.details.safeParse(proposal.details);
|
|
447
|
+
return details.success ? {
|
|
448
|
+
...proposal,
|
|
449
|
+
details: details.data
|
|
450
|
+
} : void 0;
|
|
451
|
+
};
|
|
452
|
+
const reconcile = kind.reconcile?.bind(kind);
|
|
453
|
+
const entry = {
|
|
454
|
+
skill: kind.skill,
|
|
455
|
+
invitation: kind.invitation,
|
|
456
|
+
verbs: kind.verbs,
|
|
457
|
+
openPhases: kind.openPhases ?? [],
|
|
458
|
+
statusGuidance: kind.statusGuidance,
|
|
459
|
+
statusFields: kind.statusFields,
|
|
460
|
+
detailsIssues: (details) => {
|
|
461
|
+
const parsed = kind.details.safeParse(details);
|
|
462
|
+
return parsed.success ? void 0 : issuesOf(parsed.error);
|
|
463
|
+
},
|
|
464
|
+
dismissal: (proposal, payload) => {
|
|
465
|
+
const card = typed(proposal);
|
|
466
|
+
return card === void 0 ? void 0 : kind.dismissal?.(card, payload);
|
|
467
|
+
},
|
|
468
|
+
reconcile: reconcile === void 0 ? void 0 : async (agent, proposal) => {
|
|
469
|
+
const card = typed(proposal);
|
|
470
|
+
if (card !== void 0) await reconcile(agent, card);
|
|
471
|
+
},
|
|
472
|
+
act: (agent, proposal, verb, payload) => {
|
|
473
|
+
const card = typed(proposal);
|
|
474
|
+
return card === void 0 ? Promise.resolve(refused("unavailable")) : kind.act(agent, card, verb, payload);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const dispose = this.ctx.effect(() => {
|
|
478
|
+
if (this.kinds.has(kind.action)) throw new Error(`action-cards: action "${kind.action}" is already registered`);
|
|
479
|
+
this.kinds.set(kind.action, entry);
|
|
480
|
+
return () => {
|
|
481
|
+
this.kinds.delete(kind.action);
|
|
482
|
+
this.retired.add(kind.action);
|
|
483
|
+
};
|
|
484
|
+
}, `action-cards: kind ${kind.action}`);
|
|
485
|
+
return () => {
|
|
486
|
+
dispose();
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Record a card decision in the Session log; the next model request reports it.
|
|
491
|
+
* @param agent - agent whose Session owns the card.
|
|
492
|
+
* @param decision - the card's new state.
|
|
493
|
+
* @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives.
|
|
494
|
+
*/
|
|
495
|
+
record(agent, decision) {
|
|
496
|
+
if (!this.kinds.has(decision.action) && !this.retired.has(decision.action)) throw new Error(`action-cards: cannot record a decision for unregistered action "${decision.action}"`);
|
|
497
|
+
if (DERIVED_PHASES.includes(decision.phase)) throw new Error(`action-cards: phase "${decision.phase}" is derived from the log and cannot be recorded`);
|
|
498
|
+
this.append(agent, decision);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Read the cards of one Session.
|
|
502
|
+
* @param session - a Session whose projections are prepared.
|
|
503
|
+
* @returns proposals and decisions by call id.
|
|
504
|
+
*/
|
|
505
|
+
view(session) {
|
|
506
|
+
return this.projection.stateOf(session).view;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is
|
|
510
|
+
* recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind
|
|
511
|
+
* is no longer registered. Concurrent actions on one card share one operation.
|
|
512
|
+
* @param agent - live root resolved from the displayed Session.
|
|
513
|
+
* @param callId - the card's `propose_action` call id.
|
|
514
|
+
* @param verb - `dismiss` or one of the kind's verbs.
|
|
515
|
+
* @param payload - kind-defined fields such as a chosen location.
|
|
516
|
+
* @returns acceptance of background work, the decision in effect, or a refusal.
|
|
517
|
+
*/
|
|
518
|
+
act(agent, callId, verb, payload) {
|
|
519
|
+
if (this.isStopping()) return Promise.resolve(refused("closing"));
|
|
520
|
+
if (!this.ctx.agents.roots().includes(agent)) return Promise.resolve(refused("not-live-root"));
|
|
521
|
+
const proposal = ownEntry(this.projection.stateOf(agent.session).view.proposals, callId);
|
|
522
|
+
if (proposal === void 0) return Promise.resolve(refused("unknown"));
|
|
523
|
+
const kind = this.kinds.get(proposal.action);
|
|
524
|
+
if (verb === DISMISS) return this.coalesce(agent, callId, () => this.dismiss(agent, kind, proposal, payload));
|
|
525
|
+
const policy = kind === void 0 ? void 0 : ownEntry(kind.verbs, verb);
|
|
526
|
+
if (kind === void 0 || policy === void 0) return Promise.resolve(refused("unavailable"));
|
|
527
|
+
if (policy.global === true && !this.ctx.remoteAccess.allows({ kind: "global" })) return Promise.resolve(refused("access-denied"));
|
|
528
|
+
return this.coalesce(agent, callId, () => this.perform(agent, kind, proposal, verb, policy, payload));
|
|
529
|
+
}
|
|
530
|
+
/** Share one operation per card, run under the Agent's initiator, until it settles. */
|
|
531
|
+
coalesce(agent, callId, run) {
|
|
532
|
+
const key = JSON.stringify([agent.id, callId]);
|
|
533
|
+
const existing = this.actions.get(key);
|
|
534
|
+
if (existing !== void 0) return existing;
|
|
535
|
+
const operation = this.ctx.agents.withInitiator(agent, run).finally(() => {
|
|
536
|
+
this.actions.delete(key);
|
|
537
|
+
});
|
|
538
|
+
this.actions.set(key, operation);
|
|
539
|
+
return operation;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Await the kind's reconciliation. A kind that unloads meanwhile still handles the action and refuses it itself.
|
|
543
|
+
* @returns a refusal when reconciliation failed or the root closed meanwhile; undefined to continue.
|
|
544
|
+
*/
|
|
545
|
+
async reconciled(agent, kind, proposal, verb) {
|
|
546
|
+
if (kind.reconcile === void 0) return void 0;
|
|
547
|
+
try {
|
|
548
|
+
await kind.reconcile(agent, proposal);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.ctx.logger.warn("Cannot reconcile action card %s before %s: %s", proposal.callId, verb, String(error));
|
|
551
|
+
return refused("unavailable");
|
|
552
|
+
}
|
|
553
|
+
return this.ctx.agents.roots().includes(agent) ? void 0 : refused("not-live-root");
|
|
554
|
+
}
|
|
555
|
+
async dismiss(agent, kind, proposal, payload) {
|
|
556
|
+
const refusal = kind === void 0 ? void 0 : await this.reconciled(agent, kind, proposal, DISMISS);
|
|
557
|
+
if (refusal !== void 0) return refusal;
|
|
558
|
+
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId);
|
|
559
|
+
if (decision !== void 0 && (FINAL_PHASES.includes(decision.phase) || kind?.openPhases.includes(decision.phase) !== true)) return {
|
|
560
|
+
status: "settled",
|
|
561
|
+
decision
|
|
562
|
+
};
|
|
563
|
+
const data = kind?.dismissal(proposal, payload);
|
|
564
|
+
const dismissed = {
|
|
565
|
+
callId: proposal.callId,
|
|
566
|
+
action: proposal.action,
|
|
567
|
+
phase: "dismissed",
|
|
568
|
+
...data === void 0 ? {} : { data }
|
|
569
|
+
};
|
|
570
|
+
this.append(agent, dismissed);
|
|
571
|
+
return {
|
|
572
|
+
status: "settled",
|
|
573
|
+
decision: dismissed
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
async perform(agent, kind, proposal, verb, policy, payload) {
|
|
577
|
+
const refusal = await this.reconciled(agent, kind, proposal, verb);
|
|
578
|
+
if (refusal !== void 0) return refusal;
|
|
579
|
+
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId);
|
|
580
|
+
if (decision !== void 0 && FINAL_PHASES.includes(decision.phase)) return {
|
|
581
|
+
status: "settled",
|
|
582
|
+
decision
|
|
583
|
+
};
|
|
584
|
+
if (policy.writes === true && this.ctx.get("sandboxPolicy")?.resolve({ session: agent.session }).mode === "read-only") return refused("read-only");
|
|
585
|
+
return await kind.act(agent, proposal, verb, payload);
|
|
586
|
+
}
|
|
587
|
+
append(agent, decision) {
|
|
588
|
+
appendPluginRecord(agent.session, "plugin:action-cards/decision", decision);
|
|
589
|
+
}
|
|
590
|
+
/** Whether `propose_action` is declared for this agent's next step. */
|
|
591
|
+
declares(agent) {
|
|
592
|
+
const skills = this.loaded.get(agent);
|
|
593
|
+
return skills !== void 0 && [...this.kinds.values()].some((kind) => skills.has(kind.skill));
|
|
594
|
+
}
|
|
595
|
+
propose(args, exec) {
|
|
596
|
+
const agent = exec.agent;
|
|
597
|
+
if (agent === void 0 || exec.parent !== void 0) throw new ActionCardError("Call propose_action directly, not from code.", "ACTION_CARD_NESTED");
|
|
598
|
+
if (!this.ctx.agents.roots().includes(agent)) throw new ActionCardError("Cards are offered in the main conversation only.", "ACTION_CARD_ROOT");
|
|
599
|
+
if (this.projection.stateOf(agent.session).queued >= this.config.queuedMessageLimit) throw new ActionCardError("The user has queued more messages; show no card and answer briefly.", "ACTION_CARD_QUEUED");
|
|
600
|
+
const kind = this.kinds.get(args.action);
|
|
601
|
+
if (kind === void 0) throw new ActionCardError(`Unknown action "${args.action}". Registered actions: ${[...this.kinds.keys()].join(", ") || "none"}.`, "ACTION_CARD_UNKNOWN");
|
|
602
|
+
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn;
|
|
603
|
+
const loads = this.turnLoads.get(agent);
|
|
604
|
+
if (loads?.turn !== turn || !loads.skills.has(kind.skill)) throw new ActionCardError(`Load skill ${kind.skill} first, then call propose_action.`, "ACTION_CARD_SKILL");
|
|
605
|
+
if (args.title.trim() === "" || args.brief.trim() === "") throw new ActionCardError("Write a title and a brief.", "ACTION_CARD_BLANK");
|
|
606
|
+
const issues = kind.detailsIssues(args.details);
|
|
607
|
+
if (issues !== void 0) throw new ActionCardError(issues, "ACTION_CARD_DETAILS");
|
|
608
|
+
if (this.accepted.get(agent)?.turn === turn || Object.values(this.view(agent.session).proposals).some((card) => card.turn === turn)) throw new ActionCardError("One card per turn; the earlier card stands.", "ACTION_CARD_LIMIT");
|
|
609
|
+
this.accepted.set(agent, {
|
|
610
|
+
turn,
|
|
611
|
+
callId: exec.callId
|
|
612
|
+
});
|
|
613
|
+
return {
|
|
614
|
+
action: args.action,
|
|
615
|
+
title: args.title.trim(),
|
|
616
|
+
invitation: kind.invitation
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
isStopping() {
|
|
620
|
+
return isPluginStopping(this.owner, this.closing);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
})();
|
|
624
|
+
//#endregion
|
|
625
|
+
export { ActionCardError, ActionCards, ActionCards as default, Config, isPluginStopping };
|