@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
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
+
var useValue = arguments.length > 2;
|
|
3
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
+
}
|
|
6
|
+
return useValue ? value : void 0;
|
|
7
|
+
};
|
|
8
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
+
var _, done = false;
|
|
14
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
+
var context = {};
|
|
16
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
+
if (kind === "accessor") {
|
|
21
|
+
if (result === void 0) continue;
|
|
22
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
+
}
|
|
27
|
+
else if (_ = accept(result)) {
|
|
28
|
+
if (kind === "field") initializers.unshift(_);
|
|
29
|
+
else descriptor[key] = _;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
+
done = true;
|
|
34
|
+
};
|
|
35
|
+
/** Optional action cards below an answer: the `propose_action` tool, card state, user actions and status notices for registered kinds. */
|
|
36
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
39
|
+
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm';
|
|
40
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
41
|
+
import { appendPluginRecord, SessionSeq } from '@deepseek-ai/dsh-session';
|
|
42
|
+
import { actionCardProjection, PROPOSE_ACTION } from "./projection.js";
|
|
43
|
+
/** Deployment-tunable limits for offering cards. */
|
|
44
|
+
export const Config = z.object({
|
|
45
|
+
/** Queued next-turn user messages at or above which `propose_action` refuses, so no card is offered. */
|
|
46
|
+
queuedMessageLimit: z.number().int().min(1).default(2),
|
|
47
|
+
}).prefault({});
|
|
48
|
+
/** The user action every card offers; the shared service records it. */
|
|
49
|
+
const DISMISS = 'dismiss';
|
|
50
|
+
/** Phases the projection derives from the log; no decision may record them. */
|
|
51
|
+
const DERIVED_PHASES = ['replaced'];
|
|
52
|
+
/** Phases after which no user action changes the card. */
|
|
53
|
+
const FINAL_PHASES = ['dismissed', ...DERIVED_PHASES];
|
|
54
|
+
/** Tool whose successful loads activate `propose_action`; its `name` argument names the skill. */
|
|
55
|
+
const SKILL_TOOL = 'skill';
|
|
56
|
+
const skillArguments = z.object({ name: z.string() });
|
|
57
|
+
const STATUS_PREAMBLE = 'Latest status of action cards from user actions and background work. '
|
|
58
|
+
+ 'These are status facts, not a request to start or retry work. Use these receipts instead of the earlier proposal when describing progress.';
|
|
59
|
+
const STATUS_CLOSING = 'Continue with the user’s current request.';
|
|
60
|
+
/**
|
|
61
|
+
* Whether a plugin must refuse new work: its fiber or an ancestor is disposed or unloading, since a parent's disposal
|
|
62
|
+
* begins before the plugin's own effects run, or the plugin's own drain has begun.
|
|
63
|
+
* @param owner - the plugin's fiber.
|
|
64
|
+
* @param draining - whether the plugin's drain effect has started.
|
|
65
|
+
* @returns whether the plugin is stopping.
|
|
66
|
+
*/
|
|
67
|
+
export function isPluginStopping(owner, draining) {
|
|
68
|
+
for (let fiber = owner;; fiber = fiber.parent.fiber) {
|
|
69
|
+
if (fiber.uid === null || fiber.state === 5 /* FiberState.UNLOADING */)
|
|
70
|
+
return true;
|
|
71
|
+
if (fiber.parent.fiber === fiber)
|
|
72
|
+
return draining;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A refused `propose_action` call; the message tells the model what to do instead. */
|
|
76
|
+
export class ActionCardError extends HarnessError {
|
|
77
|
+
}
|
|
78
|
+
/** Own entries only: browser-supplied ids such as `toString` never reach inherited object members. */
|
|
79
|
+
function ownEntry(record, key) {
|
|
80
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
81
|
+
}
|
|
82
|
+
function refused(reason) {
|
|
83
|
+
return { status: 'refused', reason };
|
|
84
|
+
}
|
|
85
|
+
function issuesOf(error) {
|
|
86
|
+
return 'Invalid details: ' + error.issues.map(issue => `${['details', ...issue.path.map(String)].join('.')}: ${issue.message}`).join('; ');
|
|
87
|
+
}
|
|
88
|
+
/** Card tool, kind registry, Session projection, user actions and status notices. */
|
|
89
|
+
let ActionCards = (() => {
|
|
90
|
+
let _classSuper = TypertRemoteService;
|
|
91
|
+
let _instanceExtraInitializers = [];
|
|
92
|
+
let _act_decorators;
|
|
93
|
+
return class ActionCards extends _classSuper {
|
|
94
|
+
static {
|
|
95
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
96
|
+
_act_decorators = [Remote];
|
|
97
|
+
__esDecorate(this, null, _act_decorators, { kind: "method", name: "act", static: false, private: false, access: { has: obj => "act" in obj, get: obj => obj.act }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
98
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
99
|
+
}
|
|
100
|
+
config = __runInitializers(this, _instanceExtraInitializers);
|
|
101
|
+
static inject = ['tools', 'agents', 'sessionProjections', 'remoteAccess'];
|
|
102
|
+
static Config = Config;
|
|
103
|
+
projection;
|
|
104
|
+
kinds = new Map();
|
|
105
|
+
/**
|
|
106
|
+
* Actions whose kind unregistered. Cordis disposes a plugin's effects concurrently, so a kind's unload drain can
|
|
107
|
+
* still record the outcome of its in-flight work after its registration is gone.
|
|
108
|
+
*/
|
|
109
|
+
retired = new Set();
|
|
110
|
+
/** Skills of registered kinds each live agent has loaded; `propose_action` is declared while one is still registered. */
|
|
111
|
+
loaded = new Map();
|
|
112
|
+
/** Kind skills each agent loaded in its latest turn. */
|
|
113
|
+
turnLoads = new Map();
|
|
114
|
+
/** The call accepted in each agent's latest turn, until its result fails. */
|
|
115
|
+
accepted = new Map();
|
|
116
|
+
actions = new Map();
|
|
117
|
+
closing = false;
|
|
118
|
+
owner;
|
|
119
|
+
/**
|
|
120
|
+
* @param ctx - Owning plugin context.
|
|
121
|
+
* @param config - Validated card limits; direct composition without a config gets the schema defaults.
|
|
122
|
+
*/
|
|
123
|
+
constructor(ctx, config = Config.parse({})) {
|
|
124
|
+
super(ctx, 'actionCards');
|
|
125
|
+
this.config = config;
|
|
126
|
+
this.owner = ctx.fiber;
|
|
127
|
+
}
|
|
128
|
+
async [Service.init]() {
|
|
129
|
+
this.projection = await this.ctx.sessionProjections.register(actionCardProjection);
|
|
130
|
+
this.ctx.tools.register(defineTool({
|
|
131
|
+
name: PROPOSE_ACTION,
|
|
132
|
+
description: 'Show the user one optional action card under this answer. Call only as the skill that names the action instructs.',
|
|
133
|
+
parameters: {
|
|
134
|
+
action: { type: 'string', required: true, description: 'Action name from the skill.' },
|
|
135
|
+
title: { type: 'string', required: true, description: 'Card title.' },
|
|
136
|
+
brief: { type: 'string', required: true, description: 'What the action does, in the user\'s language.' },
|
|
137
|
+
details: { type: 'object', additionalProperties: true, required: true, description: 'Fields the skill lists for this action.' },
|
|
138
|
+
},
|
|
139
|
+
output: {
|
|
140
|
+
schema: {
|
|
141
|
+
type: 'object', additionalProperties: false,
|
|
142
|
+
properties: {
|
|
143
|
+
action: { type: 'string', required: true }, title: { type: 'string', required: true }, invitation: { type: 'string', required: true },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
render: (_args, value) => [{ type: 'text', text: `Card shown below your answer. Do not restate it. ${value.invitation}` }],
|
|
147
|
+
},
|
|
148
|
+
execute: (args, exec) => Promise.resolve(this.propose(args, exec)),
|
|
149
|
+
}), { when: ({ agent }) => this.declares(agent) });
|
|
150
|
+
this.ctx.on('tools/result', (exec, result) => {
|
|
151
|
+
const agent = exec.agent;
|
|
152
|
+
if (agent === undefined)
|
|
153
|
+
return;
|
|
154
|
+
if (exec.name === PROPOSE_ACTION) {
|
|
155
|
+
if (result.isError && this.accepted.get(agent)?.callId === exec.callId)
|
|
156
|
+
this.accepted.delete(agent);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Loads by subagents or from code activate nothing: `propose_action` refuses both callers.
|
|
160
|
+
if (exec.parent !== undefined || !this.ctx.agents.roots().includes(agent))
|
|
161
|
+
return;
|
|
162
|
+
const skill = exec.name === SKILL_TOOL && !result.isError ? skillArguments.safeParse(exec.arguments) : undefined;
|
|
163
|
+
if (skill?.success !== true || ![...this.kinds.values()].some(kind => kind.skill === skill.data.name))
|
|
164
|
+
return;
|
|
165
|
+
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn;
|
|
166
|
+
const loads = this.turnLoads.get(agent);
|
|
167
|
+
if (loads?.turn === turn)
|
|
168
|
+
loads.skills.add(skill.data.name);
|
|
169
|
+
else
|
|
170
|
+
this.turnLoads.set(agent, { turn, skills: new Set([skill.data.name]) });
|
|
171
|
+
this.loaded.set(agent, new Set([...this.loaded.get(agent) ?? [], skill.data.name]));
|
|
172
|
+
});
|
|
173
|
+
this.ctx.on('agent/disposed', ({ agent }) => {
|
|
174
|
+
this.loaded.delete(agent);
|
|
175
|
+
this.turnLoads.delete(agent);
|
|
176
|
+
this.accepted.delete(agent);
|
|
177
|
+
});
|
|
178
|
+
this.ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
179
|
+
const decision = await next();
|
|
180
|
+
if (decision.kind === 'reject' || signal.aborted || this.isStopping())
|
|
181
|
+
return decision;
|
|
182
|
+
const state = this.projection.stateOf(agent.session);
|
|
183
|
+
const pending = Object.values(state.pendingStatus);
|
|
184
|
+
if (pending.length === 0)
|
|
185
|
+
return decision;
|
|
186
|
+
const receipts = Object.values(state.view.decisions).filter(receipt => Object.hasOwn(state.pendingStatus, receipt.callId));
|
|
187
|
+
const guidance = new Set(receipts.flatMap(receipt => this.kinds.get(receipt.action)?.statusGuidance ?? []));
|
|
188
|
+
const updates = receipts.map((receipt) => {
|
|
189
|
+
const fields = this.kinds.get(receipt.action)?.statusFields;
|
|
190
|
+
const data = receipt.data === undefined || fields === undefined ? receipt.data
|
|
191
|
+
: Object.fromEntries(Object.entries(receipt.data).filter(([key]) => fields.includes(key)));
|
|
192
|
+
return { action: receipt.action, callId: receipt.callId, title: ownEntry(state.view.proposals, receipt.callId)?.title,
|
|
193
|
+
phase: receipt.phase, ...data === undefined ? {} : { data } };
|
|
194
|
+
});
|
|
195
|
+
return { ...decision, messages: [...decision.messages, createUserMessage({
|
|
196
|
+
source: { kind: 'action-cards', through: SessionSeq(Math.max(...pending)), form: 'notice', summary: 'Action card status updated' },
|
|
197
|
+
content: [{ type: 'text', text: [STATUS_PREAMBLE, ...guidance, STATUS_CLOSING].join(' ') + '\n' + JSON.stringify(updates) }],
|
|
198
|
+
})] };
|
|
199
|
+
}, { prepend: true });
|
|
200
|
+
this.ctx.effect(() => async () => {
|
|
201
|
+
this.closing = true;
|
|
202
|
+
await Promise.allSettled([...this.actions.values()]);
|
|
203
|
+
}, 'action-cards: drain user actions');
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Register one action kind for the caller's lifetime.
|
|
207
|
+
* @param kind - the kind's skill, details schema, verbs and handler.
|
|
208
|
+
* @returns disposer removing the kind; actions on its cards are then unavailable.
|
|
209
|
+
* @throws when another kind already registered the same action.
|
|
210
|
+
*/
|
|
211
|
+
register(kind) {
|
|
212
|
+
const typed = (proposal) => {
|
|
213
|
+
const details = kind.details.safeParse(proposal.details);
|
|
214
|
+
return details.success ? { ...proposal, details: details.data } : undefined;
|
|
215
|
+
};
|
|
216
|
+
const reconcile = kind.reconcile?.bind(kind);
|
|
217
|
+
const entry = {
|
|
218
|
+
skill: kind.skill, invitation: kind.invitation, verbs: kind.verbs, openPhases: kind.openPhases ?? [],
|
|
219
|
+
statusGuidance: kind.statusGuidance, statusFields: kind.statusFields,
|
|
220
|
+
detailsIssues: (details) => {
|
|
221
|
+
const parsed = kind.details.safeParse(details);
|
|
222
|
+
return parsed.success ? undefined : issuesOf(parsed.error);
|
|
223
|
+
},
|
|
224
|
+
dismissal: (proposal, payload) => {
|
|
225
|
+
const card = typed(proposal);
|
|
226
|
+
return card === undefined ? undefined : kind.dismissal?.(card, payload);
|
|
227
|
+
},
|
|
228
|
+
// A card whose details no longer validate never reached the kind, so the kind has nothing to reconcile.
|
|
229
|
+
reconcile: reconcile === undefined ? undefined : async (agent, proposal) => {
|
|
230
|
+
const card = typed(proposal);
|
|
231
|
+
if (card !== undefined)
|
|
232
|
+
await reconcile(agent, card);
|
|
233
|
+
},
|
|
234
|
+
act: (agent, proposal, verb, payload) => {
|
|
235
|
+
const card = typed(proposal);
|
|
236
|
+
return card === undefined ? Promise.resolve(refused('unavailable')) : kind.act(agent, card, verb, payload);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const dispose = this.ctx.effect(() => {
|
|
240
|
+
if (this.kinds.has(kind.action))
|
|
241
|
+
throw new Error(`action-cards: action "${kind.action}" is already registered`);
|
|
242
|
+
this.kinds.set(kind.action, entry);
|
|
243
|
+
return () => {
|
|
244
|
+
this.kinds.delete(kind.action);
|
|
245
|
+
this.retired.add(kind.action);
|
|
246
|
+
};
|
|
247
|
+
}, `action-cards: kind ${kind.action}`);
|
|
248
|
+
return () => { void dispose(); };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Record a card decision in the Session log; the next model request reports it.
|
|
252
|
+
* @param agent - agent whose Session owns the card.
|
|
253
|
+
* @param decision - the card's new state.
|
|
254
|
+
* @throws when no kind ever registered the decision's action with this service, or its phase is one the projection derives.
|
|
255
|
+
*/
|
|
256
|
+
record(agent, decision) {
|
|
257
|
+
if (!this.kinds.has(decision.action) && !this.retired.has(decision.action)) {
|
|
258
|
+
throw new Error(`action-cards: cannot record a decision for unregistered action "${decision.action}"`);
|
|
259
|
+
}
|
|
260
|
+
if (DERIVED_PHASES.includes(decision.phase))
|
|
261
|
+
throw new Error(`action-cards: phase "${decision.phase}" is derived from the log and cannot be recorded`);
|
|
262
|
+
this.append(agent, decision);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Read the cards of one Session.
|
|
266
|
+
* @param session - a Session whose projections are prepared.
|
|
267
|
+
* @returns proposals and decisions by call id.
|
|
268
|
+
*/
|
|
269
|
+
view(session) {
|
|
270
|
+
return this.projection.stateOf(session).view;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Apply a user action to a logged card. Shared checks run first, then the kind reconciles the card; `dismiss` is
|
|
274
|
+
* recorded here and every other verb goes to the card's kind. `dismiss` also works on an unstarted card whose kind
|
|
275
|
+
* is no longer registered. Concurrent actions on one card share one operation.
|
|
276
|
+
* @param agent - live root resolved from the displayed Session.
|
|
277
|
+
* @param callId - the card's `propose_action` call id.
|
|
278
|
+
* @param verb - `dismiss` or one of the kind's verbs.
|
|
279
|
+
* @param payload - kind-defined fields such as a chosen location.
|
|
280
|
+
* @returns acceptance of background work, the decision in effect, or a refusal.
|
|
281
|
+
*/
|
|
282
|
+
act(agent, callId, verb, payload) {
|
|
283
|
+
if (this.isStopping())
|
|
284
|
+
return Promise.resolve(refused('closing'));
|
|
285
|
+
if (!this.ctx.agents.roots().includes(agent))
|
|
286
|
+
return Promise.resolve(refused('not-live-root'));
|
|
287
|
+
const proposal = ownEntry(this.projection.stateOf(agent.session).view.proposals, callId);
|
|
288
|
+
if (proposal === undefined)
|
|
289
|
+
return Promise.resolve(refused('unknown'));
|
|
290
|
+
const kind = this.kinds.get(proposal.action);
|
|
291
|
+
if (verb === DISMISS)
|
|
292
|
+
return this.coalesce(agent, callId, () => this.dismiss(agent, kind, proposal, payload));
|
|
293
|
+
const policy = kind === undefined ? undefined : ownEntry(kind.verbs, verb);
|
|
294
|
+
if (kind === undefined || policy === undefined)
|
|
295
|
+
return Promise.resolve(refused('unavailable'));
|
|
296
|
+
if (policy.global === true && !this.ctx.remoteAccess.allows({ kind: 'global' }))
|
|
297
|
+
return Promise.resolve(refused('access-denied'));
|
|
298
|
+
return this.coalesce(agent, callId, () => this.perform(agent, kind, proposal, verb, policy, payload));
|
|
299
|
+
}
|
|
300
|
+
/** Share one operation per card, run under the Agent's initiator, until it settles. */
|
|
301
|
+
coalesce(agent, callId, run) {
|
|
302
|
+
const key = JSON.stringify([agent.id, callId]);
|
|
303
|
+
const existing = this.actions.get(key);
|
|
304
|
+
if (existing !== undefined)
|
|
305
|
+
return existing;
|
|
306
|
+
const operation = this.ctx.agents.withInitiator(agent, run).finally(() => { this.actions.delete(key); });
|
|
307
|
+
this.actions.set(key, operation);
|
|
308
|
+
return operation;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Await the kind's reconciliation. A kind that unloads meanwhile still handles the action and refuses it itself.
|
|
312
|
+
* @returns a refusal when reconciliation failed or the root closed meanwhile; undefined to continue.
|
|
313
|
+
*/
|
|
314
|
+
async reconciled(agent, kind, proposal, verb) {
|
|
315
|
+
if (kind.reconcile === undefined)
|
|
316
|
+
return undefined;
|
|
317
|
+
try {
|
|
318
|
+
await kind.reconcile(agent, proposal);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
this.ctx.logger.warn('Cannot reconcile action card %s before %s: %s', proposal.callId, verb, String(error));
|
|
322
|
+
return refused('unavailable');
|
|
323
|
+
}
|
|
324
|
+
return this.ctx.agents.roots().includes(agent) ? undefined : refused('not-live-root');
|
|
325
|
+
}
|
|
326
|
+
async dismiss(agent, kind, proposal, payload) {
|
|
327
|
+
const refusal = kind === undefined ? undefined : await this.reconciled(agent, kind, proposal, DISMISS);
|
|
328
|
+
if (refusal !== undefined)
|
|
329
|
+
return refusal;
|
|
330
|
+
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId);
|
|
331
|
+
// Without its kind no recorded phase is known to be open, and the kind may return to act on it.
|
|
332
|
+
if (decision !== undefined && (FINAL_PHASES.includes(decision.phase) || kind?.openPhases.includes(decision.phase) !== true)) {
|
|
333
|
+
return { status: 'settled', decision };
|
|
334
|
+
}
|
|
335
|
+
const data = kind?.dismissal(proposal, payload);
|
|
336
|
+
const dismissed = { callId: proposal.callId, action: proposal.action, phase: 'dismissed', ...data === undefined ? {} : { data } };
|
|
337
|
+
this.append(agent, dismissed);
|
|
338
|
+
return { status: 'settled', decision: dismissed };
|
|
339
|
+
}
|
|
340
|
+
async perform(agent, kind, proposal, verb, policy, payload) {
|
|
341
|
+
const refusal = await this.reconciled(agent, kind, proposal, verb);
|
|
342
|
+
if (refusal !== undefined)
|
|
343
|
+
return refusal;
|
|
344
|
+
const decision = ownEntry(this.projection.stateOf(agent.session).view.decisions, proposal.callId);
|
|
345
|
+
if (decision !== undefined && FINAL_PHASES.includes(decision.phase))
|
|
346
|
+
return { status: 'settled', decision };
|
|
347
|
+
if (policy.writes === true && this.ctx.get('sandboxPolicy')?.resolve({ session: agent.session }).mode === 'read-only')
|
|
348
|
+
return refused('read-only');
|
|
349
|
+
return await kind.act(agent, proposal, verb, payload);
|
|
350
|
+
}
|
|
351
|
+
append(agent, decision) {
|
|
352
|
+
appendPluginRecord(agent.session, 'plugin:action-cards/decision', decision);
|
|
353
|
+
}
|
|
354
|
+
/** Whether `propose_action` is declared for this agent's next step. */
|
|
355
|
+
declares(agent) {
|
|
356
|
+
const skills = this.loaded.get(agent);
|
|
357
|
+
return skills !== undefined && [...this.kinds.values()].some(kind => skills.has(kind.skill));
|
|
358
|
+
}
|
|
359
|
+
propose(args, exec) {
|
|
360
|
+
const agent = exec.agent;
|
|
361
|
+
// Step admission declares the tool only to an agent; calls dispatched from code are not logged as tool calls,
|
|
362
|
+
// so no card could follow them.
|
|
363
|
+
if (agent === undefined || exec.parent !== undefined)
|
|
364
|
+
throw new ActionCardError('Call propose_action directly, not from code.', 'ACTION_CARD_NESTED');
|
|
365
|
+
// Only a live root can act on a card, so a subagent's card could never be used.
|
|
366
|
+
if (!this.ctx.agents.roots().includes(agent))
|
|
367
|
+
throw new ActionCardError('Cards are offered in the main conversation only.', 'ACTION_CARD_ROOT');
|
|
368
|
+
if (this.projection.stateOf(agent.session).queued >= this.config.queuedMessageLimit) {
|
|
369
|
+
throw new ActionCardError('The user has queued more messages; show no card and answer briefly.', 'ACTION_CARD_QUEUED');
|
|
370
|
+
}
|
|
371
|
+
const kind = this.kinds.get(args.action);
|
|
372
|
+
// Admission is decided when the step starts, so the last kind can unregister before this call runs.
|
|
373
|
+
if (kind === undefined)
|
|
374
|
+
throw new ActionCardError(`Unknown action "${args.action}". Registered actions: ${[...this.kinds.keys()].join(', ') || 'none'}.`, 'ACTION_CARD_UNKNOWN');
|
|
375
|
+
const turn = this.ctx.agents.turnBoundaryOf(agent.session).lastTurn;
|
|
376
|
+
const loads = this.turnLoads.get(agent);
|
|
377
|
+
if (loads?.turn !== turn || !loads.skills.has(kind.skill)) {
|
|
378
|
+
throw new ActionCardError(`Load skill ${kind.skill} first, then call propose_action.`, 'ACTION_CARD_SKILL');
|
|
379
|
+
}
|
|
380
|
+
if (args.title.trim() === '' || args.brief.trim() === '')
|
|
381
|
+
throw new ActionCardError('Write a title and a brief.', 'ACTION_CARD_BLANK');
|
|
382
|
+
const issues = kind.detailsIssues(args.details);
|
|
383
|
+
if (issues !== undefined)
|
|
384
|
+
throw new ActionCardError(issues, 'ACTION_CARD_DETAILS');
|
|
385
|
+
if (this.accepted.get(agent)?.turn === turn || Object.values(this.view(agent.session).proposals).some(card => card.turn === turn)) {
|
|
386
|
+
throw new ActionCardError('One card per turn; the earlier card stands.', 'ACTION_CARD_LIMIT');
|
|
387
|
+
}
|
|
388
|
+
this.accepted.set(agent, { turn, callId: exec.callId });
|
|
389
|
+
return { action: args.action, title: args.title.trim(), invitation: kind.invitation };
|
|
390
|
+
}
|
|
391
|
+
isStopping() {
|
|
392
|
+
return isPluginStopping(this.owner, this.closing);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
})();
|
|
396
|
+
export { ActionCards };
|
|
397
|
+
export default ActionCards;
|
|
398
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Recover card proposals from logged `propose_action` calls and decisions from plugin records. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
5
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
|
|
6
|
+
/** Model-facing name of the card tool. */
|
|
7
|
+
export declare const PROPOSE_ACTION = "propose_action";
|
|
8
|
+
/** Kind-defined JSON object of a proposal or decision. */
|
|
9
|
+
export declare const fieldsSchema: z.ZodRecord<z.ZodString, z.ZodJSONSchema>;
|
|
10
|
+
/** Arguments of a `propose_action` call; blank titles and briefs create no card. */
|
|
11
|
+
export declare const argumentsSchema: z.ZodObject<{
|
|
12
|
+
action: z.ZodString;
|
|
13
|
+
title: z.ZodString;
|
|
14
|
+
brief: z.ZodString;
|
|
15
|
+
details: z.ZodRecord<z.ZodString, z.ZodJSONSchema>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
/** Recorded decision of one card. */
|
|
18
|
+
export declare const decisionSchema: z.ZodObject<{
|
|
19
|
+
callId: z.ZodPipe<z.ZodString, z.ZodTransform<ToolCallId, string>>;
|
|
20
|
+
action: z.ZodString;
|
|
21
|
+
phase: z.ZodString;
|
|
22
|
+
data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
declare const stateSchema: z.ZodObject<{
|
|
25
|
+
inheritedEventCount: z.ZodPipe<z.ZodNumber, z.ZodTransform<SessionLogOffset, number>>;
|
|
26
|
+
view: z.ZodObject<{
|
|
27
|
+
proposals: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
28
|
+
callId: z.ZodPipe<z.ZodString, z.ZodTransform<ToolCallId, string>>;
|
|
29
|
+
turn: z.ZodNumber;
|
|
30
|
+
action: z.ZodString;
|
|
31
|
+
title: z.ZodString;
|
|
32
|
+
brief: z.ZodString;
|
|
33
|
+
details: z.ZodRecord<z.ZodString, z.ZodJSONSchema>;
|
|
34
|
+
}, z.core.$strict>>;
|
|
35
|
+
decisions: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
36
|
+
callId: z.ZodPipe<z.ZodString, z.ZodTransform<ToolCallId, string>>;
|
|
37
|
+
action: z.ZodString;
|
|
38
|
+
phase: z.ZodString;
|
|
39
|
+
data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodJSONSchema>>;
|
|
40
|
+
}, z.core.$strict>>;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
calls: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
43
|
+
details: z.ZodRecord<z.ZodString, z.ZodJSONSchema>;
|
|
44
|
+
action: z.ZodString;
|
|
45
|
+
title: z.ZodString;
|
|
46
|
+
brief: z.ZodString;
|
|
47
|
+
turn: z.ZodNumber;
|
|
48
|
+
}, z.core.$strict>>;
|
|
49
|
+
admittedTurn: z.ZodNullable<z.ZodNumber>;
|
|
50
|
+
pendingStatus: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
51
|
+
queued: z.ZodNumber;
|
|
52
|
+
}, z.core.$strip>;
|
|
53
|
+
/** Fold state; `view` is also the Client wire value. */
|
|
54
|
+
export type ActionCardState = z.infer<typeof stateSchema>;
|
|
55
|
+
/** Type of {@link actionCardProjection}: the fold plus its Client view wire. */
|
|
56
|
+
export type ActionCardProjectionDefinition = Omit<ProjectionDefinition<'actionCards', ActionCardState>, 'wire'> & {
|
|
57
|
+
readonly wire: NonNullable<ProjectionDefinition<'actionCards', ActionCardState>['wire']>;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Full-Session fold. A successful `propose_action` result admits the call as a card, at most one per turn;
|
|
61
|
+
* the first admitted call of a turn wins. A newer card of any kind replaces every unstarted earlier card.
|
|
62
|
+
* Events inherited by a forked Session create no proposals or decisions.
|
|
63
|
+
*/
|
|
64
|
+
export declare const actionCardProjection: ActionCardProjectionDefinition;
|
|
65
|
+
export {};
|
|
66
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Recover card proposals from logged `propose_action` calls and decisions from plugin records. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { pluginRecordOf, SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
5
|
+
/** Model-facing name of the card tool. */
|
|
6
|
+
export const PROPOSE_ACTION = 'propose_action';
|
|
7
|
+
/** Kind-defined JSON object of a proposal or decision. */
|
|
8
|
+
export const fieldsSchema = z.record(z.string(), z.json());
|
|
9
|
+
/** Arguments of a `propose_action` call; blank titles and briefs create no card. */
|
|
10
|
+
export const argumentsSchema = z.object({
|
|
11
|
+
action: z.string().min(1),
|
|
12
|
+
title: z.string().trim().min(1),
|
|
13
|
+
brief: z.string().trim().min(1),
|
|
14
|
+
details: fieldsSchema,
|
|
15
|
+
});
|
|
16
|
+
const proposalSchema = z.object({
|
|
17
|
+
callId: z.string().min(1).transform(ToolCallId),
|
|
18
|
+
turn: z.number().int().nonnegative(),
|
|
19
|
+
action: z.string().min(1),
|
|
20
|
+
title: z.string().min(1),
|
|
21
|
+
brief: z.string().min(1),
|
|
22
|
+
details: fieldsSchema,
|
|
23
|
+
}).strict();
|
|
24
|
+
/** Recorded decision of one card. */
|
|
25
|
+
export const decisionSchema = z.object({
|
|
26
|
+
callId: z.string().min(1).transform(ToolCallId),
|
|
27
|
+
action: z.string().min(1),
|
|
28
|
+
phase: z.string().min(1),
|
|
29
|
+
data: fieldsSchema.optional(),
|
|
30
|
+
}).strict();
|
|
31
|
+
const viewSchema = z.object({
|
|
32
|
+
proposals: z.record(z.string(), proposalSchema),
|
|
33
|
+
decisions: z.record(z.string(), decisionSchema),
|
|
34
|
+
});
|
|
35
|
+
const stateSchema = z.object({
|
|
36
|
+
/** Fork-inherited prefix length; inherited events create no proposals or decisions. */
|
|
37
|
+
inheritedEventCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(SessionLogOffset),
|
|
38
|
+
view: viewSchema,
|
|
39
|
+
/** `propose_action` calls of the open turn still waiting for their result. */
|
|
40
|
+
calls: z.record(z.string(), proposalSchema.omit({ callId: true })),
|
|
41
|
+
/** Turn of the latest admitted proposal; later calls of that turn create no card. */
|
|
42
|
+
admittedTurn: z.number().int().nullable(),
|
|
43
|
+
/** Decisions not yet reported to the model, by call id, with the seq of their record. */
|
|
44
|
+
pendingStatus: z.record(z.string(), z.number()),
|
|
45
|
+
/** User messages still queued for later turns, folded from inbox splices; the card tool compares it with its limit. */
|
|
46
|
+
queued: z.number().int().nonnegative(),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Full-Session fold. A successful `propose_action` result admits the call as a card, at most one per turn;
|
|
50
|
+
* the first admitted call of a turn wins. A newer card of any kind replaces every unstarted earlier card.
|
|
51
|
+
* Events inherited by a forked Session create no proposals or decisions.
|
|
52
|
+
*/
|
|
53
|
+
export const actionCardProjection = {
|
|
54
|
+
key: 'actionCards', stateVersion: 2, stateSchema,
|
|
55
|
+
init: (_header, inheritedEventCount) => ({
|
|
56
|
+
inheritedEventCount, view: { proposals: {}, decisions: {} }, calls: {}, admittedTurn: null, pendingStatus: {}, queued: 0,
|
|
57
|
+
}),
|
|
58
|
+
apply: (state, event) => {
|
|
59
|
+
if (event.seq < state.inheritedEventCount)
|
|
60
|
+
return state;
|
|
61
|
+
switch (event.type) {
|
|
62
|
+
case 'turn/start':
|
|
63
|
+
// A turn's calls settle inside it; a call left without a result never becomes a card.
|
|
64
|
+
return Object.keys(state.calls).length === 0 ? state : { ...state, calls: {} };
|
|
65
|
+
case 'agent/inbox/spliced':
|
|
66
|
+
if (event.data.target !== 'next-turn')
|
|
67
|
+
return state;
|
|
68
|
+
return { ...state, queued: Math.max(0, state.queued - (event.data.removedCount ?? 0) + event.data.inserted.length) };
|
|
69
|
+
case 'user/message': {
|
|
70
|
+
if (event.data.source.kind !== 'action-cards')
|
|
71
|
+
return state;
|
|
72
|
+
const through = event.data.source.through;
|
|
73
|
+
return { ...state, pendingStatus: Object.fromEntries(Object.entries(state.pendingStatus).filter(([, seq]) => seq > through)) };
|
|
74
|
+
}
|
|
75
|
+
case 'tool/call': {
|
|
76
|
+
if (event.data.name !== PROPOSE_ACTION)
|
|
77
|
+
return state;
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(event.data.arguments);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return state;
|
|
84
|
+
} // Malformed model JSON failed in the tool too and creates no card.
|
|
85
|
+
const parsed = argumentsSchema.safeParse(raw);
|
|
86
|
+
if (!parsed.success)
|
|
87
|
+
return state;
|
|
88
|
+
return { ...state, calls: { ...state.calls, [event.data.callId]: { turn: event.data.turn, ...parsed.data } } };
|
|
89
|
+
}
|
|
90
|
+
case 'tool/result': {
|
|
91
|
+
const callId = event.data.message.toolCallId;
|
|
92
|
+
const call = Object.hasOwn(state.calls, callId) ? state.calls[callId] : undefined;
|
|
93
|
+
if (call === undefined)
|
|
94
|
+
return state;
|
|
95
|
+
const calls = Object.fromEntries(Object.entries(state.calls).filter(([id]) => id !== callId));
|
|
96
|
+
if (event.data.message.isError === true || state.admittedTurn === call.turn)
|
|
97
|
+
return { ...state, calls };
|
|
98
|
+
const decisions = { ...state.view.decisions };
|
|
99
|
+
// A newer card supersedes every unstarted card; the model made that choice, so no status is reported.
|
|
100
|
+
for (const earlier of Object.values(state.view.proposals)) {
|
|
101
|
+
if (decisions[earlier.callId] === undefined)
|
|
102
|
+
decisions[earlier.callId] = { callId: earlier.callId, action: earlier.action, phase: 'replaced' };
|
|
103
|
+
}
|
|
104
|
+
return { ...state, calls, admittedTurn: call.turn,
|
|
105
|
+
view: { proposals: { ...state.view.proposals, [callId]: { callId, ...call } }, decisions } };
|
|
106
|
+
}
|
|
107
|
+
default: {
|
|
108
|
+
const record = pluginRecordOf(event);
|
|
109
|
+
if (record?.type !== 'plugin:action-cards/decision')
|
|
110
|
+
return state;
|
|
111
|
+
// A malformed or newer record is skipped; throwing here would fail every later read of this Session.
|
|
112
|
+
const decision = decisionSchema.safeParse(record.data);
|
|
113
|
+
if (!decision.success)
|
|
114
|
+
return state;
|
|
115
|
+
return { ...state, view: { ...state.view, decisions: { ...state.view.decisions, [decision.data.callId]: decision.data } },
|
|
116
|
+
pendingStatus: { ...state.pendingStatus, [decision.data.callId]: event.seq } };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
wire: { summary: false, viewSchema, view: (state) => state.view },
|
|
121
|
+
};
|
|
122
|
+
//# sourceMappingURL=projection.js.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Card proposals, recorded decisions and user-action results shared by every action kind. */
|
|
2
|
+
import type { ContextFormed } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand';
|
|
4
|
+
import type { SessionSeq } from '@deepseek-ai/dsh-session/types';
|
|
5
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values';
|
|
6
|
+
/** Kind-defined JSON fields of a proposal or decision; each kind validates its own fields. */
|
|
7
|
+
export type ActionCardFields = Readonly<Record<string, JsonValue>>;
|
|
8
|
+
/** One card admitted from a successful `propose_action` call. */
|
|
9
|
+
export interface ActionCardProposal {
|
|
10
|
+
/** Id of the logged tool call. */
|
|
11
|
+
readonly callId: ToolCallId;
|
|
12
|
+
/** Turn whose answer the card follows. */
|
|
13
|
+
readonly turn: number;
|
|
14
|
+
/** Registered action kind named by the call. */
|
|
15
|
+
readonly action: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
/** What the action does, in the user's language. */
|
|
18
|
+
readonly brief: string;
|
|
19
|
+
/** Fields the kind's skill asks for. */
|
|
20
|
+
readonly details: ActionCardFields;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Latest state of one card. Kinds define their own phases; `dismissed` is shared.
|
|
24
|
+
* `replaced` (a later proposal superseded an unstarted card) is derived from the
|
|
25
|
+
* log and never recorded.
|
|
26
|
+
*/
|
|
27
|
+
export interface ActionCardDecision {
|
|
28
|
+
readonly callId: ToolCallId;
|
|
29
|
+
readonly action: string;
|
|
30
|
+
readonly phase: string;
|
|
31
|
+
/** Kind-defined fields such as a chosen location or a created package. */
|
|
32
|
+
readonly data?: ActionCardFields | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Open-Session proposals and decisions by call id, excluded from Session summaries. */
|
|
35
|
+
export interface ActionCardView {
|
|
36
|
+
readonly proposals: Readonly<Record<string, ActionCardProposal>>;
|
|
37
|
+
readonly decisions: Readonly<Record<string, ActionCardDecision>>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Result of a user action. `accepted` starts background work that records its
|
|
41
|
+
* own progress; `settled` returns the decision now in effect; `refused` changed
|
|
42
|
+
* nothing; `unconfirmed` lost its outcome to an unloading owner, which the
|
|
43
|
+
* kind reconciles when the Session reopens.
|
|
44
|
+
*/
|
|
45
|
+
export type ActionCardResult = {
|
|
46
|
+
readonly status: 'accepted';
|
|
47
|
+
} | {
|
|
48
|
+
readonly status: 'settled';
|
|
49
|
+
readonly decision: ActionCardDecision;
|
|
50
|
+
} | {
|
|
51
|
+
readonly status: 'refused';
|
|
52
|
+
readonly reason: string;
|
|
53
|
+
} | {
|
|
54
|
+
readonly status: 'unconfirmed';
|
|
55
|
+
readonly reason: string;
|
|
56
|
+
};
|
|
57
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
58
|
+
interface MessageSourceMap {
|
|
59
|
+
/** Card status notice; other readers preserve the recorded text without this plugin.
|
|
60
|
+
* The producer uses through only to acknowledge delivered decisions.
|
|
61
|
+
* @persistenceAttribution
|
|
62
|
+
*/
|
|
63
|
+
'action-cards': {
|
|
64
|
+
kind: 'action-cards';
|
|
65
|
+
through: SessionSeq;
|
|
66
|
+
} & ContextFormed;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
70
|
+
interface PluginRecordMap {
|
|
71
|
+
/** Dismissals and kind-defined progress of action cards. */
|
|
72
|
+
'plugin:action-cards/decision': ActionCardDecision;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
76
|
+
interface SessionProjectionMap {
|
|
77
|
+
/** Action card proposals and decisions for the currently open Session. */
|
|
78
|
+
actionCards: SessionProjectionSummaryExcluded<ActionCardView>;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=types.d.ts.map
|