@stackstackstack/dsh-agent 0.1.5
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 +6 -0
- package/README.md +121 -0
- package/README.zh.md +121 -0
- package/lib/index.js +794 -0
- package/lib/invariant.js +23 -0
- package/lib/types/consumed-work.d.ts +39 -0
- package/lib/types/consumed-work.js +92 -0
- package/lib/types/dispatch.d.ts +111 -0
- package/lib/types/dispatch.js +95 -0
- package/lib/types/inbox.d.ts +92 -0
- package/lib/types/inbox.js +185 -0
- package/lib/types/index.d.ts +385 -0
- package/lib/types/index.js +474 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +24 -0
- package/lib/types/model-selection.d.ts +36 -0
- package/lib/types/model-selection.js +53 -0
- package/lib/types/runtime-types.d.ts +324 -0
- package/lib/types/runtime-types.js +8 -0
- package/lib/types/types.d.ts +25 -0
- package/lib/types/types.js +7 -0
- package/package.json +58 -0
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/** Package-owned agent lifecycle invariants. @module @stackstackstack/dsh-agent/invariant */
|
|
3
|
+
const PACKAGE_NAME = "@stackstackstack/dsh-agent";
|
|
4
|
+
/** Cordis companion plugin name. */
|
|
5
|
+
const name = "agent-invariant";
|
|
6
|
+
/** Services required before the companion can register. */
|
|
7
|
+
const inject = ["invariants"];
|
|
8
|
+
/** Install the agent contribution into its child registration fiber. */
|
|
9
|
+
const install = (ctx, fail) => {
|
|
10
|
+
const lastStatus = /* @__PURE__ */ new WeakMap();
|
|
11
|
+
ctx.on("agent/status", ({ agent, status }) => {
|
|
12
|
+
if (lastStatus.get(agent) === status) fail(`agent/status repeated ${status} (no-op transition)`);
|
|
13
|
+
lastStatus.set(agent, status);
|
|
14
|
+
}, { global: true });
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Register the agent invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How one agent log accounts for the work it consumed.
|
|
3
|
+
*
|
|
4
|
+
* The turn and step vocabulary alone cannot answer this. A turn that stops
|
|
5
|
+
* before its first step leaves a `turn/end` shaped exactly like the balanced
|
|
6
|
+
* no-op turns a rejection or an empty claim produces, so reading turns in
|
|
7
|
+
* isolation either credits cut-short work as finished or convicts every no-op.
|
|
8
|
+
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
|
|
9
|
+
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
|
|
10
|
+
* separates a turn claiming its input from work being dropped unrun.
|
|
11
|
+
*
|
|
12
|
+
* @module @stackstackstack/dsh-agent/consumed-work
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionEvent } from '@stackstackstack/dsh-session';
|
|
15
|
+
/** How one agent log accounts for the work it consumed. */
|
|
16
|
+
export interface ConsumedWork {
|
|
17
|
+
/**
|
|
18
|
+
* The latest closed turn that accounts for consumed work: one that entered a
|
|
19
|
+
* model step, or one that claimed inbox input and then failed, was stopped,
|
|
20
|
+
* or was rejected. Absent when no turn closed over any work.
|
|
21
|
+
*/
|
|
22
|
+
readonly end?: SessionEvent<'turn/end'>;
|
|
23
|
+
/**
|
|
24
|
+
* Whether accepted work was cancelled out of the inbox, unrun, after that
|
|
25
|
+
* turn. This is the only account of input a cancellation took before any turn
|
|
26
|
+
* could open over it — no `turn/end` describes it.
|
|
27
|
+
*/
|
|
28
|
+
readonly droppedUnrun: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Fold one agent log, or an owned suffix of one, into its account of consumed
|
|
32
|
+
* work. Single pass, and every input is the log itself: no caller has to sample
|
|
33
|
+
* live state before cancelling, so a cancellation issued by anyone — the owner's
|
|
34
|
+
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
|
|
35
|
+
* @param events - the log, or an owned suffix, to fold.
|
|
36
|
+
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function foldConsumedWork(events: readonly SessionEvent[]): ConsumedWork;
|
|
39
|
+
//# sourceMappingURL=consumed-work.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How one agent log accounts for the work it consumed.
|
|
3
|
+
*
|
|
4
|
+
* The turn and step vocabulary alone cannot answer this. A turn that stops
|
|
5
|
+
* before its first step leaves a `turn/end` shaped exactly like the balanced
|
|
6
|
+
* no-op turns a rejection or an empty claim produces, so reading turns in
|
|
7
|
+
* isolation either credits cut-short work as finished or convicts every no-op.
|
|
8
|
+
* The missing fact is the inbox's own record: {@link Inbox} logs each mutation
|
|
9
|
+
* with `removedCount` and marks a cancellation `outcome: 'canceled'`, which
|
|
10
|
+
* separates a turn claiming its input from work being dropped unrun.
|
|
11
|
+
*
|
|
12
|
+
* @module @stackstackstack/dsh-agent/consumed-work
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Whether a turn that consumed input but never reached a step ends in a way
|
|
16
|
+
* that accounts for that input. Only a `completed` end does not: it had
|
|
17
|
+
* nothing left to run once its claim was rewritten away. A `blocked` end is
|
|
18
|
+
* that input's ending too — the pre-step rejection that produced it discarded
|
|
19
|
+
* the claimed messages, so the work it took will never run.
|
|
20
|
+
* @param reason - the turn's recorded ending.
|
|
21
|
+
* @returns whether the ending accounts for the input the turn took.
|
|
22
|
+
*/
|
|
23
|
+
function accountsForClaim(reason) {
|
|
24
|
+
switch (reason.kind) {
|
|
25
|
+
case 'completed':
|
|
26
|
+
return false;
|
|
27
|
+
case 'blocked':
|
|
28
|
+
case 'aborted':
|
|
29
|
+
case 'interrupted':
|
|
30
|
+
case 'error':
|
|
31
|
+
return true;
|
|
32
|
+
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
|
|
33
|
+
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
|
|
34
|
+
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
|
|
35
|
+
* consumed input must not read as success. */
|
|
36
|
+
default:
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fold one agent log, or an owned suffix of one, into its account of consumed
|
|
42
|
+
* work. Single pass, and every input is the log itself: no caller has to sample
|
|
43
|
+
* live state before cancelling, so a cancellation issued by anyone — the owner's
|
|
44
|
+
* teardown, an ancestor's interrupt, an unloading plugin — reads the same.
|
|
45
|
+
* @param events - the log, or an owned suffix, to fold.
|
|
46
|
+
* @returns the accounting turn when one closed, and whether work was dropped unrun after it.
|
|
47
|
+
*/
|
|
48
|
+
export function foldConsumedWork(events) {
|
|
49
|
+
const stepped = new Set();
|
|
50
|
+
const claimed = new Set();
|
|
51
|
+
let open;
|
|
52
|
+
let end;
|
|
53
|
+
let droppedUnrun = false;
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
switch (event.type) {
|
|
56
|
+
case 'turn/start':
|
|
57
|
+
open = event.data.turn;
|
|
58
|
+
break;
|
|
59
|
+
case 'step/start':
|
|
60
|
+
stepped.add(event.data.turn);
|
|
61
|
+
break;
|
|
62
|
+
case 'agent/inbox/spliced': {
|
|
63
|
+
const { removedCount, outcome, inserted } = event.data;
|
|
64
|
+
if (removedCount === undefined)
|
|
65
|
+
break;
|
|
66
|
+
// A replacement keeps the work pending under a new identity, so only a
|
|
67
|
+
// cancellation that leaves nothing behind drops it.
|
|
68
|
+
if (outcome === 'canceled')
|
|
69
|
+
droppedUnrun ||= inserted.length === 0;
|
|
70
|
+
// Claims are the loop's own step-boundary reads, always inside a turn.
|
|
71
|
+
else if (open !== undefined)
|
|
72
|
+
claimed.add(open);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'turn/end': {
|
|
76
|
+
const { turn, reason } = event.data;
|
|
77
|
+
open = undefined;
|
|
78
|
+
if (stepped.delete(turn) || (claimed.delete(turn) && accountsForClaim(reason))) {
|
|
79
|
+
end = event;
|
|
80
|
+
// Anything dropped before this turn closed is what its own ending
|
|
81
|
+
// reports; only a later drop is still unaccounted for.
|
|
82
|
+
droppedUnrun = false;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ...end === undefined ? {} : { end }, droppedUnrun };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=consumed-work.js.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
|
|
3
|
+
* {@link agentEvents} couples the agent subject to its scope carrier, so the
|
|
4
|
+
* scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
|
|
5
|
+
* loop driver) build it once in the agent's constructor and reuse it.
|
|
6
|
+
* @module @stackstackstack/dsh-agent/dispatch
|
|
7
|
+
*/
|
|
8
|
+
import type { Context, Events } from '@deepseek-ai/cordis';
|
|
9
|
+
import type { Scoped } from '@stackstackstack/dsh-scope';
|
|
10
|
+
import type { AssembleContext } from '@stackstackstack/dsh-system-prompt';
|
|
11
|
+
import type { Agent } from './runtime-types.ts';
|
|
12
|
+
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
|
|
13
|
+
type Params<F> = F extends (...args: infer P) => unknown ? P : never;
|
|
14
|
+
/** Extract the return type from an event handler type. */
|
|
15
|
+
type Return<F> = F extends (...args: never[]) => infer R ? R : never;
|
|
16
|
+
/**
|
|
17
|
+
* The event names whose subject is an agent: the handler's first parameter is
|
|
18
|
+
* a payload object carrying the `agent` subject AND the handler declares a
|
|
19
|
+
* `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
|
|
20
|
+
* accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
|
|
21
|
+
* whose parameter tuple would satisfy a bare rest-tuple check via callability)
|
|
22
|
+
* out of the fused-dispatch surface.
|
|
23
|
+
*/
|
|
24
|
+
export type AgentSubjectEvent = {
|
|
25
|
+
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown ? P extends [infer Payload, ...unknown[]] ? Payload extends {
|
|
26
|
+
agent: Agent;
|
|
27
|
+
} ? K : never : never : never;
|
|
28
|
+
}[keyof Events];
|
|
29
|
+
/** The full payload object of one agent-subject event. */
|
|
30
|
+
type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never;
|
|
31
|
+
/** The event arguments AFTER the payload: the waterfall `next` when present. */
|
|
32
|
+
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [unknown, ...infer R] ? R : never;
|
|
33
|
+
/**
|
|
34
|
+
* The payload as emit-side callers pass it: the full payload minus the agent
|
|
35
|
+
* field, which the fused dispatcher injects so subject and scope key cannot
|
|
36
|
+
* diverge.
|
|
37
|
+
*/
|
|
38
|
+
type PayloadRest<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>;
|
|
39
|
+
/**
|
|
40
|
+
* The fused dispatcher {@link agentEvents} returns: each method dispatches the
|
|
41
|
+
* named agent-subject event with the agent's scope carrier as `thisArg` and
|
|
42
|
+
* the agent itself injected into the payload.
|
|
43
|
+
*/
|
|
44
|
+
export interface AgentEventDispatch {
|
|
45
|
+
/**
|
|
46
|
+
* Fire-and-forget notification in the agent's scope. Every listener is
|
|
47
|
+
* invoked; synchronous throws and returned-promise rejections are logged and
|
|
48
|
+
* contained per listener, so a notification cannot veto lifecycle progress
|
|
49
|
+
* or starve a later observer.
|
|
50
|
+
* @param name - the agent-subject event to emit.
|
|
51
|
+
* @param payload - the event's payload fields; `agent` is injected.
|
|
52
|
+
*/
|
|
53
|
+
emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void;
|
|
54
|
+
/**
|
|
55
|
+
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
|
|
56
|
+
* @param name - the agent-subject event to dispatch.
|
|
57
|
+
* @param payload - the event's payload fields; `agent` is injected.
|
|
58
|
+
* @returns the serial chain's result (the first bail value, if any).
|
|
59
|
+
*/
|
|
60
|
+
serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>;
|
|
61
|
+
/**
|
|
62
|
+
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
|
63
|
+
* declared event parameters already end with the `next` callback, so `rest`
|
|
64
|
+
* is exactly the event's arguments after the payload — the final element
|
|
65
|
+
* being the innermost `next` (the default the listener chain wraps).
|
|
66
|
+
* @param name - the agent-subject event to dispatch.
|
|
67
|
+
* @param payload - the event's payload fields; `agent` is injected.
|
|
68
|
+
* @param rest - the event's arguments after the payload (the `next` callback).
|
|
69
|
+
* @returns the waterfall's composed result.
|
|
70
|
+
*/
|
|
71
|
+
waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build the fused scope carrier for one agent subject.
|
|
75
|
+
*
|
|
76
|
+
* The carrier is a stateless routing object. {@link agentEvents} accepts an
|
|
77
|
+
* existing carrier, so callers that dispatch repeatedly for the same agent
|
|
78
|
+
* (the loop driver) build it once in the agent's constructor and reuse it,
|
|
79
|
+
* keeping hot-path dispatches allocation-free.
|
|
80
|
+
* @param agent - the subject agent and scope key.
|
|
81
|
+
* @returns the carrier passed as the event dispatcher `this` value.
|
|
82
|
+
*/
|
|
83
|
+
export declare function agentCarrier(agent: Agent): Scoped<Agent>;
|
|
84
|
+
/**
|
|
85
|
+
* Build a dispatcher that couples the agent subject to its scope carrier.
|
|
86
|
+
* @param ctx - the context to dispatch through (any context of the app).
|
|
87
|
+
* @param agent - the subject agent; also the scope-carrier key.
|
|
88
|
+
* @param carrier - the scope carrier to dispatch through; defaults to
|
|
89
|
+
* {@link agentCarrier} for the agent. Pass a constructor-built carrier to
|
|
90
|
+
* avoid rebuilding it for every dispatch.
|
|
91
|
+
* @returns the fused dispatcher.
|
|
92
|
+
*/
|
|
93
|
+
export declare function agentEvents(ctx: Context, agent: Agent, carrier?: Scoped<Agent>): AgentEventDispatch;
|
|
94
|
+
/**
|
|
95
|
+
* Emit one contained agent notification without allocating a retained dispatcher.
|
|
96
|
+
* @param ctx - the context to dispatch through.
|
|
97
|
+
* @param agent - the subject agent and scope key.
|
|
98
|
+
* @param name - the agent-subject event to emit.
|
|
99
|
+
* @param payload - the event's payload fields; `agent` is injected.
|
|
100
|
+
*/
|
|
101
|
+
export declare function emitAgentEvent<K extends AgentSubjectEvent>(ctx: Context, agent: Agent, name: K, payload: PayloadRest<K>): void;
|
|
102
|
+
/**
|
|
103
|
+
* Build the prompt assembly context with agent and scope set together, so
|
|
104
|
+
* agent-scoped prompt and tool contributions cannot be silently omitted.
|
|
105
|
+
* @param agent - the agent the assembly is for.
|
|
106
|
+
* @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
|
|
107
|
+
* @returns the context to pass to `assemble()`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext;
|
|
110
|
+
export {};
|
|
111
|
+
//# sourceMappingURL=dispatch.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-scoped dispatch and prompt assembly helpers. The fused dispatcher
|
|
3
|
+
* {@link agentEvents} couples the agent subject to its scope carrier, so the
|
|
4
|
+
* scope key and the payload's `agent` cannot diverge; repeat dispatchers (the
|
|
5
|
+
* loop driver) build it once in the agent's constructor and reuse it.
|
|
6
|
+
* @module @stackstackstack/dsh-agent/dispatch
|
|
7
|
+
*/
|
|
8
|
+
import { scopeTarget } from '@stackstackstack/dsh-scope';
|
|
9
|
+
/**
|
|
10
|
+
* Build the fused scope carrier for one agent subject.
|
|
11
|
+
*
|
|
12
|
+
* The carrier is a stateless routing object. {@link agentEvents} accepts an
|
|
13
|
+
* existing carrier, so callers that dispatch repeatedly for the same agent
|
|
14
|
+
* (the loop driver) build it once in the agent's constructor and reuse it,
|
|
15
|
+
* keeping hot-path dispatches allocation-free.
|
|
16
|
+
* @param agent - the subject agent and scope key.
|
|
17
|
+
* @returns the carrier passed as the event dispatcher `this` value.
|
|
18
|
+
*/
|
|
19
|
+
export function agentCarrier(agent) {
|
|
20
|
+
return scopeTarget(agent, agent);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build a dispatcher that couples the agent subject to its scope carrier.
|
|
24
|
+
* @param ctx - the context to dispatch through (any context of the app).
|
|
25
|
+
* @param agent - the subject agent; also the scope-carrier key.
|
|
26
|
+
* @param carrier - the scope carrier to dispatch through; defaults to
|
|
27
|
+
* {@link agentCarrier} for the agent. Pass a constructor-built carrier to
|
|
28
|
+
* avoid rebuilding it for every dispatch.
|
|
29
|
+
* @returns the fused dispatcher.
|
|
30
|
+
*/
|
|
31
|
+
export function agentEvents(ctx, agent, carrier = agentCarrier(agent)) {
|
|
32
|
+
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
|
33
|
+
// fused (carrier, name, payload, ...rest) tuple is provably a valid argument
|
|
34
|
+
// list for the matching thisArg overload, but TypeScript cannot relate the
|
|
35
|
+
// generic Tail<K> spread back to that overload's conditional parameter
|
|
36
|
+
// tuple — hence one contained, shape-preserving cast per method.
|
|
37
|
+
const fused = (payload) =>
|
|
38
|
+
// The dispatcher owns the subject injection; callers pass PayloadRest, so
|
|
39
|
+
// the fused record is exactly the declared payload. The spread comes
|
|
40
|
+
// first, so a structurally acceptable payload that happens to carry an
|
|
41
|
+
// `agent` field can never override the injected subject.
|
|
42
|
+
({ ...payload, agent });
|
|
43
|
+
return {
|
|
44
|
+
emit(name, payload) {
|
|
45
|
+
// Cordis emit invokes callbacks through Array.map: one synchronous throw
|
|
46
|
+
// starves later listeners, and returned promises are discarded. Agent
|
|
47
|
+
// notifications are non-vetoing, so resolve the same filtered callback
|
|
48
|
+
// set ourselves and contain both failure modes independently.
|
|
49
|
+
const args = [carrier, name, fused(payload)];
|
|
50
|
+
const callbacks = ctx.events.dispatch('emit', args);
|
|
51
|
+
for (const callback of callbacks) {
|
|
52
|
+
try {
|
|
53
|
+
const returned = callback(...args);
|
|
54
|
+
void Promise.resolve(returned).catch((error) => {
|
|
55
|
+
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async serial(name, payload) {
|
|
64
|
+
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
|
|
65
|
+
const serial = ctx.serial;
|
|
66
|
+
return await serial(carrier, name, fused(payload));
|
|
67
|
+
},
|
|
68
|
+
waterfall(name, payload, ...rest) {
|
|
69
|
+
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
|
|
70
|
+
const waterfall = ctx.waterfall;
|
|
71
|
+
return waterfall(carrier, name, fused(payload), ...rest);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Emit one contained agent notification without allocating a retained dispatcher.
|
|
77
|
+
* @param ctx - the context to dispatch through.
|
|
78
|
+
* @param agent - the subject agent and scope key.
|
|
79
|
+
* @param name - the agent-subject event to emit.
|
|
80
|
+
* @param payload - the event's payload fields; `agent` is injected.
|
|
81
|
+
*/
|
|
82
|
+
export function emitAgentEvent(ctx, agent, name, payload) {
|
|
83
|
+
agentEvents(ctx, agent).emit(name, payload);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the prompt assembly context with agent and scope set together, so
|
|
87
|
+
* agent-scoped prompt and tool contributions cannot be silently omitted.
|
|
88
|
+
* @param agent - the agent the assembly is for.
|
|
89
|
+
* @param signal - the current turn's explicit control signal, when assembly belongs to a turn.
|
|
90
|
+
* @returns the context to pass to `assemble()`.
|
|
91
|
+
*/
|
|
92
|
+
export function assembleContextFor(agent, signal) {
|
|
93
|
+
return { agent, scope: agent, ...signal === undefined ? {} : { signal } };
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=dispatch.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental projection of durable agent inbox events.
|
|
3
|
+
*
|
|
4
|
+
* @module @stackstackstack/dsh-agent/inbox
|
|
5
|
+
*/
|
|
6
|
+
import type { MessageId } from '@stackstackstack/dsh-llm';
|
|
7
|
+
import type { Session, UserMessage } from '@stackstackstack/dsh-session';
|
|
8
|
+
import type { InboxTarget } from './types.ts';
|
|
9
|
+
/** Live notifications committed by inbox mutations. */
|
|
10
|
+
export interface InboxNotifications {
|
|
11
|
+
/** Publish one inserted message. */
|
|
12
|
+
inserted(message: UserMessage): void;
|
|
13
|
+
/** Publish one discarded message. */
|
|
14
|
+
discarded(message: UserMessage): void;
|
|
15
|
+
/** Publish one claimed message inside its owning turn. */
|
|
16
|
+
claimed(message: UserMessage, turn: number): void;
|
|
17
|
+
}
|
|
18
|
+
/** A replay-once projection that incrementally consumes later inbox splices. */
|
|
19
|
+
export declare class Inbox {
|
|
20
|
+
private readonly session;
|
|
21
|
+
private readonly notifications;
|
|
22
|
+
private readonly state;
|
|
23
|
+
constructor(session: Session, notifications: InboxNotifications);
|
|
24
|
+
/** Prompts awaiting individual turns. */
|
|
25
|
+
get nextTurn(): readonly UserMessage[];
|
|
26
|
+
/** Input awaiting the next step boundary. */
|
|
27
|
+
get nextStep(): readonly UserMessage[];
|
|
28
|
+
/** Whether either pending-message list contains work. */
|
|
29
|
+
get hasPending(): boolean;
|
|
30
|
+
/** Durably cancel all pending input, clearing next-step before next-turn. */
|
|
31
|
+
clear(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Remove and return the complete batch proposed for one step, publishing
|
|
34
|
+
* each claimed message. The durable splices are pure deletions.
|
|
35
|
+
* @param target - whether this boundary also consumes one queued turn.
|
|
36
|
+
* @param turn - turn that will own the claimed batch.
|
|
37
|
+
* @returns next-step input followed by the queued turn, when requested.
|
|
38
|
+
* @internal - The agent loop's step-boundary operation, not a plugin extension point.
|
|
39
|
+
*/
|
|
40
|
+
claim(target: InboxTarget, turn: number): UserMessage[];
|
|
41
|
+
/**
|
|
42
|
+
* Append one message to a pending list and durably record the insertion.
|
|
43
|
+
* @param target - pending list to extend.
|
|
44
|
+
* @param message - message to append.
|
|
45
|
+
* @throws if the message identity is already pending.
|
|
46
|
+
*/
|
|
47
|
+
append(target: InboxTarget, message: UserMessage): void;
|
|
48
|
+
/**
|
|
49
|
+
* Prepend one message to a pending list and durably record the insertion.
|
|
50
|
+
* @param target - pending list to extend.
|
|
51
|
+
* @param message - message to prepend.
|
|
52
|
+
* @throws if the message identity is already pending.
|
|
53
|
+
*/
|
|
54
|
+
prepend(target: InboxTarget, message: UserMessage): void;
|
|
55
|
+
/**
|
|
56
|
+
* Replace one pending message in place, possibly changing its identity. A
|
|
57
|
+
* successful replacement publishes the old message as discarded and the new
|
|
58
|
+
* message as inserted.
|
|
59
|
+
* @param messageId - identity of the pending message to replace.
|
|
60
|
+
* @param newMessage - replacement message.
|
|
61
|
+
* @returns whether the message was still pending.
|
|
62
|
+
* @throws if the replacement duplicates another pending message identity.
|
|
63
|
+
*/
|
|
64
|
+
replace(messageId: MessageId, newMessage: UserMessage): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Remove one pending message and durably record its cancellation.
|
|
67
|
+
* @param messageId - identity of the pending message to remove.
|
|
68
|
+
* @returns whether the message was still pending.
|
|
69
|
+
*/
|
|
70
|
+
remove(messageId: MessageId): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Apply standard splice semantics and durably record the normalized result.
|
|
73
|
+
* The durable event commits before the live projection mutates, so synchronous
|
|
74
|
+
* `session/event` observers see the pre-splice lists and can reconstruct the
|
|
75
|
+
* removed messages from the normalized coordinates.
|
|
76
|
+
* @param target - pending list to mutate.
|
|
77
|
+
* @param start - splice position.
|
|
78
|
+
* @param deleteCount - maximum number of messages to remove.
|
|
79
|
+
* @param inserted - messages to insert at the resolved position.
|
|
80
|
+
* @returns messages removed by the splice.
|
|
81
|
+
*/
|
|
82
|
+
splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];
|
|
83
|
+
/** Locate one pending identity across both owned lists. */
|
|
84
|
+
private locate;
|
|
85
|
+
/** Commit one normalized mutation and publish its live notifications. */
|
|
86
|
+
private mutate;
|
|
87
|
+
/** Apply one normalized durable splice to the projection. */
|
|
88
|
+
private apply;
|
|
89
|
+
/** Validate one normalized splice against the current projection. */
|
|
90
|
+
private validate;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=inbox.d.ts.map
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental projection of durable agent inbox events.
|
|
3
|
+
*
|
|
4
|
+
* @module @stackstackstack/dsh-agent/inbox
|
|
5
|
+
*/
|
|
6
|
+
/** A replay-once projection that incrementally consumes later inbox splices. */
|
|
7
|
+
export class Inbox {
|
|
8
|
+
session;
|
|
9
|
+
notifications;
|
|
10
|
+
state = { 'next-turn': [], 'next-step': [] };
|
|
11
|
+
constructor(session, notifications) {
|
|
12
|
+
this.session = session;
|
|
13
|
+
this.notifications = notifications;
|
|
14
|
+
for (const event of session.events.slice(session.header.seedLength ?? 0)) {
|
|
15
|
+
if (event.type !== 'agent/inbox/spliced')
|
|
16
|
+
continue;
|
|
17
|
+
try {
|
|
18
|
+
this.apply(event.data);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Prompts awaiting individual turns. */
|
|
26
|
+
get nextTurn() {
|
|
27
|
+
return this.state['next-turn'];
|
|
28
|
+
}
|
|
29
|
+
/** Input awaiting the next step boundary. */
|
|
30
|
+
get nextStep() {
|
|
31
|
+
return this.state['next-step'];
|
|
32
|
+
}
|
|
33
|
+
/** Whether either pending-message list contains work. */
|
|
34
|
+
get hasPending() {
|
|
35
|
+
return this.nextTurn.length > 0 || this.nextStep.length > 0;
|
|
36
|
+
}
|
|
37
|
+
/** Durably cancel all pending input, clearing next-step before next-turn. */
|
|
38
|
+
clear() {
|
|
39
|
+
this.splice('next-step', 0, this.nextStep.length, []);
|
|
40
|
+
this.splice('next-turn', 0, this.nextTurn.length, []);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Remove and return the complete batch proposed for one step, publishing
|
|
44
|
+
* each claimed message. The durable splices are pure deletions.
|
|
45
|
+
* @param target - whether this boundary also consumes one queued turn.
|
|
46
|
+
* @param turn - turn that will own the claimed batch.
|
|
47
|
+
* @returns next-step input followed by the queued turn, when requested.
|
|
48
|
+
* @internal - The agent loop's step-boundary operation, not a plugin extension point.
|
|
49
|
+
*/
|
|
50
|
+
claim(target, turn) {
|
|
51
|
+
const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false);
|
|
52
|
+
if (target === 'next-turn') {
|
|
53
|
+
claimed.push(...this.mutate('next-turn', 0, 1, [], false));
|
|
54
|
+
}
|
|
55
|
+
for (const message of claimed)
|
|
56
|
+
this.notifications.claimed(message, turn);
|
|
57
|
+
return claimed;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Append one message to a pending list and durably record the insertion.
|
|
61
|
+
* @param target - pending list to extend.
|
|
62
|
+
* @param message - message to append.
|
|
63
|
+
* @throws if the message identity is already pending.
|
|
64
|
+
*/
|
|
65
|
+
append(target, message) {
|
|
66
|
+
this.splice(target, this.state[target].length, 0, [message]);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Prepend one message to a pending list and durably record the insertion.
|
|
70
|
+
* @param target - pending list to extend.
|
|
71
|
+
* @param message - message to prepend.
|
|
72
|
+
* @throws if the message identity is already pending.
|
|
73
|
+
*/
|
|
74
|
+
prepend(target, message) {
|
|
75
|
+
this.splice(target, 0, 0, [message]);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Replace one pending message in place, possibly changing its identity. A
|
|
79
|
+
* successful replacement publishes the old message as discarded and the new
|
|
80
|
+
* message as inserted.
|
|
81
|
+
* @param messageId - identity of the pending message to replace.
|
|
82
|
+
* @param newMessage - replacement message.
|
|
83
|
+
* @returns whether the message was still pending.
|
|
84
|
+
* @throws if the replacement duplicates another pending message identity.
|
|
85
|
+
*/
|
|
86
|
+
replace(messageId, newMessage) {
|
|
87
|
+
const location = this.locate(messageId);
|
|
88
|
+
if (location === undefined)
|
|
89
|
+
return false;
|
|
90
|
+
this.splice(location.target, location.index, 1, [newMessage]);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Remove one pending message and durably record its cancellation.
|
|
95
|
+
* @param messageId - identity of the pending message to remove.
|
|
96
|
+
* @returns whether the message was still pending.
|
|
97
|
+
*/
|
|
98
|
+
remove(messageId) {
|
|
99
|
+
const location = this.locate(messageId);
|
|
100
|
+
if (location === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
this.splice(location.target, location.index, 1, []);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Apply standard splice semantics and durably record the normalized result.
|
|
107
|
+
* The durable event commits before the live projection mutates, so synchronous
|
|
108
|
+
* `session/event` observers see the pre-splice lists and can reconstruct the
|
|
109
|
+
* removed messages from the normalized coordinates.
|
|
110
|
+
* @param target - pending list to mutate.
|
|
111
|
+
* @param start - splice position.
|
|
112
|
+
* @param deleteCount - maximum number of messages to remove.
|
|
113
|
+
* @param inserted - messages to insert at the resolved position.
|
|
114
|
+
* @returns messages removed by the splice.
|
|
115
|
+
*/
|
|
116
|
+
splice(target, start, deleteCount, inserted) {
|
|
117
|
+
return this.mutate(target, start, deleteCount, inserted, true);
|
|
118
|
+
}
|
|
119
|
+
/** Locate one pending identity across both owned lists. */
|
|
120
|
+
locate(messageId) {
|
|
121
|
+
for (const target of ['next-turn', 'next-step']) {
|
|
122
|
+
const index = this.state[target].findIndex(message => message.id === messageId);
|
|
123
|
+
if (index >= 0)
|
|
124
|
+
return { target, index };
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
/** Commit one normalized mutation and publish its live notifications. */
|
|
129
|
+
mutate(target, start, deleteCount, inserted, discardRemoved) {
|
|
130
|
+
const inbox = this.state[target];
|
|
131
|
+
const truncatedStart = Math.trunc(start);
|
|
132
|
+
const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart;
|
|
133
|
+
const actualStart = offset < 0
|
|
134
|
+
? Math.max(inbox.length + offset, 0)
|
|
135
|
+
: Math.min(offset, inbox.length);
|
|
136
|
+
const truncatedDeleteCount = Math.trunc(deleteCount);
|
|
137
|
+
const actualDeleteCount = Math.min(Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), inbox.length - actualStart);
|
|
138
|
+
if (actualDeleteCount === 0 && inserted.length === 0)
|
|
139
|
+
return [];
|
|
140
|
+
const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined;
|
|
141
|
+
const splice = {
|
|
142
|
+
target,
|
|
143
|
+
start: actualStart,
|
|
144
|
+
...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }),
|
|
145
|
+
inserted,
|
|
146
|
+
...(outcome === undefined ? {} : { outcome }),
|
|
147
|
+
};
|
|
148
|
+
this.validate(splice);
|
|
149
|
+
const event = this.session.append('agent/inbox/spliced', splice);
|
|
150
|
+
const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted);
|
|
151
|
+
if (discardRemoved) {
|
|
152
|
+
for (const message of removed)
|
|
153
|
+
this.notifications.discarded(message);
|
|
154
|
+
}
|
|
155
|
+
for (const message of event.data.inserted)
|
|
156
|
+
this.notifications.inserted(message);
|
|
157
|
+
return removed;
|
|
158
|
+
}
|
|
159
|
+
/** Apply one normalized durable splice to the projection. */
|
|
160
|
+
apply(splice) {
|
|
161
|
+
this.validate(splice);
|
|
162
|
+
const inbox = this.state[splice.target];
|
|
163
|
+
return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted);
|
|
164
|
+
}
|
|
165
|
+
/** Validate one normalized splice against the current projection. */
|
|
166
|
+
validate(splice) {
|
|
167
|
+
const inbox = this.state[splice.target];
|
|
168
|
+
const removedCount = splice.removedCount ?? 0;
|
|
169
|
+
if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length
|
|
170
|
+
|| !Number.isSafeInteger(removedCount) || removedCount < 0
|
|
171
|
+
|| splice.start + removedCount > inbox.length) {
|
|
172
|
+
throw new Error('invalid inbox splice');
|
|
173
|
+
}
|
|
174
|
+
const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted);
|
|
175
|
+
const ids = new Set();
|
|
176
|
+
for (const message of splice.target === 'next-turn'
|
|
177
|
+
? [...candidate, ...this.nextStep]
|
|
178
|
+
: [...this.nextTurn, ...candidate]) {
|
|
179
|
+
if (ids.has(message.id))
|
|
180
|
+
throw new Error(`message "${message.id}" is already pending`);
|
|
181
|
+
ids.add(message.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=inbox.js.map
|