dsh-llm-fallbacks 0.5.0 → 0.5.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/README.md +5 -1
- package/README.zh-CN.md +5 -1
- package/dist/client/SubagentRoleBadge.d.ts +40 -15
- package/dist/client/fallbacks-store.d.ts +0 -53
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +68 -145
- package/dist/gateway.d.ts +2 -55
- package/dist/index.d.ts +0 -23
- package/dist/index.js +557 -66
- package/dist/role-notice.d.ts +156 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +151 -0
- package/dist/subagents-seam.d.ts +292 -0
- package/package.json +24 -23
- package/scripts/build-client.ts +1 -1
- package/scripts/repair-session-logs.ts +239 -57
- package/scripts/session-logs/publish.ts +79 -26
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-session role notice row (plan role-based-subagent-adoption Task 3).
|
|
3
|
+
*
|
|
4
|
+
* The child of a role-carrying dispatch gets ONE durable transcript row naming
|
|
5
|
+
* the role it was dispatched as, so the role is visible where the work is read
|
|
6
|
+
* — inside the child's own session — without reaching for live plugin state.
|
|
7
|
+
*
|
|
8
|
+
* Channel: an `agent/pre-step` listener that appends EXACTLY ONE message to the
|
|
9
|
+
* decision the loop is about to enter (`decision.messages`), which the loop
|
|
10
|
+
* then commits durably on the step's first attempt
|
|
11
|
+
* (`agent-loop/src/agent.ts:373-378` — `if (firstAttempt) for (const message of
|
|
12
|
+
* decision.messages)` the loop commits each message to the session log as a
|
|
13
|
+
* `'user/message'`). The listener is registered with `{ prepend: true }` (the
|
|
14
|
+
* `modelSwitchNotice` precedent in
|
|
15
|
+
* `core/agent/src/model-selection.ts:108-125`), so it is the OUTERMOST layer:
|
|
16
|
+
* `await next()` yields the FINAL decision every other listener shaped. The
|
|
17
|
+
* contract is EXACTLY ONE row, not its position: a plugin that registers its
|
|
18
|
+
* own `{ prepend: true }` `agent/pre-step` listener AFTER this one sits outside
|
|
19
|
+
* it and may append after the notice, and the loop commits the decision's
|
|
20
|
+
* messages in order — so the row is appended to whatever the inner listeners
|
|
21
|
+
* shaped, not guaranteed to be the last one.
|
|
22
|
+
*
|
|
23
|
+
* NOT `agent.inject()`: that queue is documented as lossy — "It may miss a
|
|
24
|
+
* request whose pre-step already claimed its batch. Cancellation or disposal
|
|
25
|
+
* may discard pending context." (`core/agent/src/runtime-types.ts:231-241`), and
|
|
26
|
+
* with `wakeup: false` an idle child is never woken to receive it. The pre-step
|
|
27
|
+
* decision has neither failure mode.
|
|
28
|
+
*
|
|
29
|
+
* Session-write contract (plan Global Constraints, hard): the row is a
|
|
30
|
+
* `user/message` built with `createUserMessage` and the sanctioned plugin source
|
|
31
|
+
* `{ kind: 'plugin', plugin: 'dsh-llm-fallbacks', form: 'notice', summary: … }`.
|
|
32
|
+
* `plugin` is a member of the FROZEN released source-kind set the V2→V3 edge
|
|
33
|
+
* classifies (`dsh-session-format-v2-to-v3/lib/index.js:14-30`: 15 kinds,
|
|
34
|
+
* includes `plugin`); a bespoke kind would write fine and then refuse at the
|
|
35
|
+
* next frozen edge with `cannot safely transform unclassified message source`
|
|
36
|
+
* (`:125`). NO custom session event type, NO `ignorable` marker — a mount-only
|
|
37
|
+
* plugin cannot publish either (`.mstar/projects/_default/references/
|
|
38
|
+
* subagent-role-badge-visible/explore-role-seam-report.md` §Q3). The guard test
|
|
39
|
+
* in `tests/role-notice.spec.ts` pins that membership against a checked-in
|
|
40
|
+
* mirror of the set.
|
|
41
|
+
*
|
|
42
|
+
* Once per child SESSION: the emitter consumes the seam's per-child record
|
|
43
|
+
* marker (`firstNoticePending`) AND a per-agent `emitted` set (mirroring the
|
|
44
|
+
* existing `dispatchInjected` pattern), both written only AFTER the row is built
|
|
45
|
+
* — so a throwing producer degrades without consuming the child's one notice.
|
|
46
|
+
* Never for a root agent, never for `inherit`/unresolved (those never get a
|
|
47
|
+
* record), never twice across steps or within-step retries.
|
|
48
|
+
*
|
|
49
|
+
* CF-6 — the emitted marker is SESSION-stable and bounded. `Agent.id` IS the
|
|
50
|
+
* child session id, and a continuable child's durable session outlives its
|
|
51
|
+
* activation, so the caller (`src/index.ts`) keeps this marker across
|
|
52
|
+
* `agent/disposed` and clears it only when the plugin itself is disposed; that
|
|
53
|
+
* is what makes the documented "exactly one notice row per child session" hold
|
|
54
|
+
* across a dispose + re-activate resume. Because it is never per-activation
|
|
55
|
+
* cleared it must be bounded: {@link markNoticeEmitted} evicts the oldest entry
|
|
56
|
+
* once {@link NOTICE_EMITTED_LIMIT} sessions are remembered (a child evicted
|
|
57
|
+
* that way can at worst be announced a second time, and the projection fold is
|
|
58
|
+
* last-wins, so no wrong role is ever produced).
|
|
59
|
+
*
|
|
60
|
+
* Degrade-never-crash: any throw in the path is contained with ONE debug line
|
|
61
|
+
* and the decision is returned UNCHANGED — a step is never broken, and no
|
|
62
|
+
* partial row is ever published.
|
|
63
|
+
*
|
|
64
|
+
* @module dsh-llm-fallbacks/role-notice
|
|
65
|
+
*/
|
|
66
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
67
|
+
import { type UserMessage } from '@deepseek-ai/dsh-llm';
|
|
68
|
+
import type { SubagentSeamRecord } from './subagents-seam.ts';
|
|
69
|
+
/**
|
|
70
|
+
* The `plugin` field of every notice this plugin writes. Stable: the durable
|
|
71
|
+
* source's provenance label is read from it (`ui-conversation`
|
|
72
|
+
* `context-provenance.ts` — "the plugin id, or the bare source kind").
|
|
73
|
+
*/
|
|
74
|
+
export declare const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
|
|
75
|
+
/**
|
|
76
|
+
* The message source KIND the notice rides. `plugin` is the sanctioned kind for
|
|
77
|
+
* a plugin-authored notice and a member of the frozen released set (see the
|
|
78
|
+
* module header); the value is exported so the guard test asserts on the exact
|
|
79
|
+
* literal the emitter uses.
|
|
80
|
+
*/
|
|
81
|
+
export declare const ROLE_NOTICE_SOURCE_KIND = "plugin";
|
|
82
|
+
/** Suffix appended when the role declares a persona that was NOT delivered. */
|
|
83
|
+
export declare const ROLE_NOTICE_PERSONA_SKIPPED_SUFFIX = " (persona not applied)";
|
|
84
|
+
/**
|
|
85
|
+
* How many child sessions whose notice was already emitted stay remembered
|
|
86
|
+
* (CF-6). The marker is session-stable — kept across `agent/disposed` so a
|
|
87
|
+
* re-activated continuable child is not announced twice — so it needs a bound:
|
|
88
|
+
* past this many sessions the oldest entry is evicted (worst case: a later
|
|
89
|
+
* re-activation of that evicted child writes a second identical row; the
|
|
90
|
+
* projection fold is last-wins, so the pill never names a wrong role).
|
|
91
|
+
*/
|
|
92
|
+
export declare const NOTICE_EMITTED_LIMIT = 256;
|
|
93
|
+
/**
|
|
94
|
+
* Mark one child session as announced, evicting the oldest remembered session
|
|
95
|
+
* once {@link NOTICE_EMITTED_LIMIT} is reached (insertion-ordered `Set`
|
|
96
|
+
* semantics: the oldest entry is the first value). A session already marked
|
|
97
|
+
* keeps its position — its marker is only ever written once.
|
|
98
|
+
*/
|
|
99
|
+
export declare function markNoticeEmitted(emitted: Set<string>, childSessionId: string): void;
|
|
100
|
+
/** One notice-message producer: role id + whether the declared persona was skipped. */
|
|
101
|
+
export type RoleNoticeBuilder = (role: string, personaNotApplied: boolean) => UserMessage;
|
|
102
|
+
/**
|
|
103
|
+
* Build the ONE role notice row (pure): `[role: <id>]`, with
|
|
104
|
+
* ` (persona not applied)` appended when the role declares a persona that Task
|
|
105
|
+
* 2's capability gate declined to deliver. The summary keeps `role: <id>` either
|
|
106
|
+
* way — it is the collapsed row's one-line account, and the durable role string
|
|
107
|
+
* must live there as well as in the content.
|
|
108
|
+
*
|
|
109
|
+
* @param role - the DECLARED RAW role id resolved at the dispatch seam.
|
|
110
|
+
* @param personaNotApplied - `true` only when a declared persona was skipped.
|
|
111
|
+
*/
|
|
112
|
+
export declare function buildRoleNotice(role: string, personaNotApplied: boolean): UserMessage;
|
|
113
|
+
/** Options for {@link installRoleNotice}. */
|
|
114
|
+
export interface RoleNoticeOptions {
|
|
115
|
+
/**
|
|
116
|
+
* The dispatch seam's per-child records, keyed by child SESSION id (which IS
|
|
117
|
+
* `Agent.id`). Read at each pre-step — never captured — so a record written by
|
|
118
|
+
* a dispatch that races the child's first step is still seen.
|
|
119
|
+
*/
|
|
120
|
+
records: Map<string, SubagentSeamRecord>;
|
|
121
|
+
/**
|
|
122
|
+
* Per-agent marker of children whose notice was already emitted (mirrors the
|
|
123
|
+
* runtime's `dispatchInjected`): the in-memory half of the once-per-child
|
|
124
|
+
* guarantee, independent of the record's `firstNoticePending` (which a record
|
|
125
|
+
* rewrite preserves deliberately) and of the loop's retry bookkeeping.
|
|
126
|
+
*
|
|
127
|
+
* SESSION-stable and bounded (CF-6): the caller keeps it across
|
|
128
|
+
* `agent/disposed` so a re-activated child is never announced twice, and
|
|
129
|
+
* {@link markNoticeEmitted} bounds it. ROOT-shared across the fibers applied
|
|
130
|
+
* over one root (CF-5), so a second fiber's emitter cannot announce a child
|
|
131
|
+
* the first already announced.
|
|
132
|
+
*/
|
|
133
|
+
emitted: Set<string>;
|
|
134
|
+
/**
|
|
135
|
+
* Message producer. Defaults to {@link buildRoleNotice}; the only reason to
|
|
136
|
+
* replace it is to pin the contained-degrade path (a throwing producer must
|
|
137
|
+
* publish no row and must NOT consume the child's one notice).
|
|
138
|
+
*/
|
|
139
|
+
buildNotice?: RoleNoticeBuilder;
|
|
140
|
+
/** Contained debug sink (at most ONE line per contained degrade). */
|
|
141
|
+
debug?: (message: string) => void;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Register the `agent/pre-step` notice emitter on `ctx`; returns its disposer
|
|
145
|
+
* (the owning fiber's teardown registers it, exactly like the seam's other
|
|
146
|
+
* listeners).
|
|
147
|
+
*
|
|
148
|
+
* Emission order per pre-step (cheapest first, all read-only until the emit):
|
|
149
|
+
* `next()` → reject/abort → empty decision → `origin === 'subagent'` → already
|
|
150
|
+
* emitted → pending record. The empty-decision guard is NOT cosmetic: the loop
|
|
151
|
+
* skips a step whose admitted messages are empty (`phase.step === 0 &&
|
|
152
|
+
* decision.messages.length === 0` → turn completed, no model call), so a notice
|
|
153
|
+
* pushed into an empty decision would turn a no-op step into a model call whose
|
|
154
|
+
* only input is our row.
|
|
155
|
+
*/
|
|
156
|
+
export declare function installRoleNotice(ctx: Context, options: RoleNoticeOptions): () => void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-projection contract of the subagent role pill (plan
|
|
3
|
+
* role-based-subagent-adoption Task 3b): the ONE key the host fold
|
|
4
|
+
* (`./role-projection.ts`) publishes and the session-header badge reads.
|
|
5
|
+
*
|
|
6
|
+
* PURE module — no `@deepseek-ai/*` import, no host-only dependency — because
|
|
7
|
+
* BOTH halves import it: the host through the seam's install point, the client
|
|
8
|
+
* through `SubagentRoleBadge`. Splitting the key (and the value guard) out of
|
|
9
|
+
* the host module is what keeps the client half free of the fold's host
|
|
10
|
+
* imports, and a single shared literal is what keeps the two halves from
|
|
11
|
+
* drifting into a silent "no pill".
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-llm-fallbacks/role-projection-key
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The projection key the role pill reads (`useProjection(ROLE_PROJECTION_KEY)`).
|
|
17
|
+
* Lower-camel like the host's own session-scoped keys (`modelSelection`,
|
|
18
|
+
* `sessionListMetadata`); the `fallbacks` prefix keeps a third-party key
|
|
19
|
+
* unambiguous inside the shared `SessionProjectionMap`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ROLE_PROJECTION_KEY = "fallbacksSubagentRole";
|
|
22
|
+
/**
|
|
23
|
+
* The client-visible value of the role projection: the DECLARED RAW role id the
|
|
24
|
+
* child was dispatched as (padding included — it is the id the settings
|
|
25
|
+
* document declares), or `null` while the child's log carries no role notice
|
|
26
|
+
* row (`./role-notice.ts`).
|
|
27
|
+
*
|
|
28
|
+
* A role id, NOT a record: the route is a SEPARATE host projection
|
|
29
|
+
* (`modelSelection`), because the notice row is written once at the child's
|
|
30
|
+
* first step while the route it ends up on can change afterwards.
|
|
31
|
+
*/
|
|
32
|
+
export type RoleProjectionValue = string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Read a projection value defensively (degrade-never-crash): only a non-blank
|
|
35
|
+
* string is a role. `undefined` — the key is not registered on this host, or no
|
|
36
|
+
* baseline/frame has carried it yet — and every foreign value read as "no
|
|
37
|
+
* role", so the pill collapses to nothing instead of naming something wrong.
|
|
38
|
+
*
|
|
39
|
+
* @param value - the raw `useProjection('<key>')` value.
|
|
40
|
+
*/
|
|
41
|
+
export declare function readRoleProjectionValue(value: unknown): string | undefined;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable role visibility for the session-header badge (plan
|
|
3
|
+
* role-based-subagent-adoption Task 3b) — ONE host-side session projection unit
|
|
4
|
+
* folding the role out of the child's OWN durable log.
|
|
5
|
+
*
|
|
6
|
+
* Data source = Task 3's notice row, the plugin's single write primitive: the
|
|
7
|
+
* fold reads the child's committed `user/message` events, keeps the one whose
|
|
8
|
+
* provenance is ours (`source.kind === 'plugin'` + `source.plugin ===
|
|
9
|
+
* ROLE_NOTICE_PLUGIN`), and parses the role out of its **content text**
|
|
10
|
+
* (`[role: <id>]`). No second write path, no new event type, no plugin-owned
|
|
11
|
+
* store: the projection is a pure READ of the log, and the host's projection
|
|
12
|
+
* cache checkpoints every registered unit at session creation, `turn/end` and
|
|
13
|
+
* disposal — so a settled child (served as an unpublished observation whose
|
|
14
|
+
* `projections` ride the follow opening snapshot) and a post-restart view both
|
|
15
|
+
* work with nothing of ours on disk.
|
|
16
|
+
*
|
|
17
|
+
* NEVER `source.summary`: summaries are truncated at
|
|
18
|
+
* `CONTEXT_SUMMARY_MAX_CHARS = 120` (`@deepseek-ai/dsh-llm` `message.ts`,
|
|
19
|
+
* applied by `boundContextSummary`) while a role id has no length bound in the
|
|
20
|
+
* settings schema — a long id would be CUT there and the pill would name a role
|
|
21
|
+
* that does not exist. The content blocks are unbounded, so the content text is
|
|
22
|
+
* the only correct source; the row's provenance is what makes the pre-filter
|
|
23
|
+
* cheap.
|
|
24
|
+
*
|
|
25
|
+
* Mount-only + STRUCTURAL: the registry and the unit contract are consumed as
|
|
26
|
+
* the local structural views below and this module value-imports no host seam,
|
|
27
|
+
* so the host calls INTO us. The declared
|
|
28
|
+
* `@deepseek-ai/dsh-session-projection` peerDependency (Task 3b L2 review M-2)
|
|
29
|
+
* serves the in-process Step-0 spec, which builds the REAL registry — being the
|
|
30
|
+
* real registry is the whole point of that proof — not a runtime import here
|
|
31
|
+
* (the badge reads its seat structurally for the same reason).
|
|
32
|
+
* The runtime shape relied on — `register({ key, stateSchema, init, apply,
|
|
33
|
+
* wire: { viewSchema, view }, stateVersion })` → unregister disposer — is the
|
|
34
|
+
* documented `ProjectionDefinition` contract of
|
|
35
|
+
* `@deepseek-ai/dsh-session-projection@0.1.5-rc.1`
|
|
36
|
+
* (`lib/types/index.d.ts:38-80,150-152`); only `.parse` is ever called on the
|
|
37
|
+
* schemas (`lib/index.js:255,305,433`).
|
|
38
|
+
*
|
|
39
|
+
* @module dsh-llm-fallbacks/role-projection
|
|
40
|
+
*/
|
|
41
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
42
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
43
|
+
import { type RoleProjectionValue } from './role-projection-key.ts';
|
|
44
|
+
/**
|
|
45
|
+
* The `.parse` face the registry uses on a definition's `stateSchema` /
|
|
46
|
+
* `wire.viewSchema` (a structural stand-in for the peer's `ZodType`, so this
|
|
47
|
+
* module needs no zod import and no new peer).
|
|
48
|
+
*/
|
|
49
|
+
interface ProjectionValueSchema<T> {
|
|
50
|
+
parse(value: unknown): T;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* One registered projection unit, as this plugin declares it (the consumed
|
|
54
|
+
* subset of the host's `ProjectionDefinition`): `init` and `apply` are PURE and
|
|
55
|
+
* SYNCHRONOUS, `state` is plain JSON (the persisted-checkpoint precondition),
|
|
56
|
+
* and `wire` publishes the client-visible value.
|
|
57
|
+
*/
|
|
58
|
+
export interface RoleProjectionUnit {
|
|
59
|
+
readonly key: string;
|
|
60
|
+
readonly stateSchema: ProjectionValueSchema<RoleProjectionState | null>;
|
|
61
|
+
init(header: unknown, inheritedEventCount: number): RoleProjectionState | null;
|
|
62
|
+
apply(state: RoleProjectionState | null, event: SessionEvent): RoleProjectionState | null;
|
|
63
|
+
readonly stateVersion: number;
|
|
64
|
+
readonly wire: {
|
|
65
|
+
readonly viewSchema: ProjectionValueSchema<RoleProjectionValue>;
|
|
66
|
+
view(state: RoleProjectionState | null): RoleProjectionValue;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The host fold state: the fork boundary the fold was initialized with, plus the
|
|
71
|
+
* resolved role once an OWN notice row has landed (`role` absent until then).
|
|
72
|
+
* An OBJECT, not a bare string, because the registry's publication rule is
|
|
73
|
+
* reference identity (`Object.is`) — the fold must be able to prove it left the
|
|
74
|
+
* state untouched, and that proof is only meaningful for a value the fold could
|
|
75
|
+
* otherwise re-allocate.
|
|
76
|
+
*
|
|
77
|
+
* `inheritedEventCount` is part of the state, not a closure constant: the
|
|
78
|
+
* registry hands `init` the exact fork-inherited prefix length and then folds
|
|
79
|
+
* the WHOLE log into `apply`, so the boundary has to survive into every `apply`
|
|
80
|
+
* call (and into a persisted checkpoint row) for the fold to skip an ancestor's
|
|
81
|
+
* events.
|
|
82
|
+
*/
|
|
83
|
+
export interface RoleProjectionState {
|
|
84
|
+
/**
|
|
85
|
+
* The exact fork-inherited prefix length `init` was handed: events with
|
|
86
|
+
* `seq < inheritedEventCount` belong to the ANCESTOR the child was seeded
|
|
87
|
+
* from, never to the child.
|
|
88
|
+
*/
|
|
89
|
+
readonly inheritedEventCount: number;
|
|
90
|
+
/** The DECLARED RAW role id, verbatim (padding included); absent until then. */
|
|
91
|
+
readonly role?: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The registry face this plugin uses (`ctx.sessionProjections`): the
|
|
95
|
+
* client-visible `register` overload plus the read faces the in-process proof
|
|
96
|
+
* reads. Structural on purpose — see the module header.
|
|
97
|
+
*/
|
|
98
|
+
export interface RoleProjectionRegistryView {
|
|
99
|
+
register(unit: RoleProjectionUnit): () => void;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The projection state/version. Bump `stateVersion` whenever the fold changes —
|
|
103
|
+
* v2 added the fork boundary (`inheritedEventCount`), so a v1 checkpoint row is
|
|
104
|
+
* NOT re-usable and must be refolded from the log.
|
|
105
|
+
*/
|
|
106
|
+
export declare const ROLE_PROJECTION_STATE_VERSION = 2;
|
|
107
|
+
/**
|
|
108
|
+
* The ONE role projection unit (plan Task 3b).
|
|
109
|
+
*
|
|
110
|
+
* `init` records the fork boundary and nothing else (a child's own log carries
|
|
111
|
+
* no role yet), `apply` skips every event BELOW that boundary — the seeded
|
|
112
|
+
* prefix is the ANCESTOR's log, and the plan pins "child whose notice never
|
|
113
|
+
* landed: no pill, never a wrong pill" — and otherwise folds last-wins over our
|
|
114
|
+
* own notice rows, returning the SAME state reference for every event it does
|
|
115
|
+
* not use (the registry's `Object.is` rule: an unchanged reference produces zero
|
|
116
|
+
* downstream work and no client publication) — including a repeated notice with
|
|
117
|
+
* the same role, which is what makes the fold idempotent under a replay.
|
|
118
|
+
*
|
|
119
|
+
* The boundary carries into the state because the registry calls `apply` with
|
|
120
|
+
* the WHOLE log (including the fork prefix) on every rung: `buildCell`,
|
|
121
|
+
* `drive`'s late-registration fold and `restore` all fold from `init` forward.
|
|
122
|
+
* The host's own sibling unit in the same tree does exactly this
|
|
123
|
+
* (`@deepseek-ai/dsh-subagent` `subagentCatalogProjectionDefinition`:
|
|
124
|
+
* `init: (_header, inheritedEventCount) => ({ inheritedEventCount })` and
|
|
125
|
+
* `apply: … || event.seq < state.inheritedEventCount → return state`).
|
|
126
|
+
*/
|
|
127
|
+
export declare const roleProjectionUnit: RoleProjectionUnit;
|
|
128
|
+
/** Options for {@link installRoleProjection}. */
|
|
129
|
+
export interface RoleProjectionOptions {
|
|
130
|
+
/** Contained debug sink (at most ONE line per contained degrade). */
|
|
131
|
+
debug?: (message: string) => void;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Register {@link roleProjectionUnit} on `ctx.sessionProjections` through the
|
|
135
|
+
* same conditional-inject idiom the plugin already uses for `settings` / `llm` /
|
|
136
|
+
* `typert`: the child activates only when the registry is composed, so a
|
|
137
|
+
* composition without it keeps the key absent and the badge renders nothing
|
|
138
|
+
* (capability absence — never a dispatch failure, never a crash).
|
|
139
|
+
*
|
|
140
|
+
* Registration is an effect on the child fiber: a second apply over the same
|
|
141
|
+
* root registers the SAME key at the SAME `stateVersion`, which the registry
|
|
142
|
+
* counts (one unit, the key survives until the last holder unloads) — so this
|
|
143
|
+
* needs no dedupe of its own, and a reshaped registry that refuses the
|
|
144
|
+
* registration degrades with one contained debug line.
|
|
145
|
+
*
|
|
146
|
+
* @param ctx - the plugin fiber's context.
|
|
147
|
+
* @param options - contained debug sink.
|
|
148
|
+
* @returns the disposer that withdraws the child (and with it the key).
|
|
149
|
+
*/
|
|
150
|
+
export declare function installRoleProjection(ctx: Context, options?: RoleProjectionOptions): () => void;
|
|
151
|
+
export {};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-seam role resolution + per-child record (plan
|
|
3
|
+
* role-based-subagent-adoption Task 1).
|
|
4
|
+
*
|
|
5
|
+
* The seam is the documented cordis service-read waterfall
|
|
6
|
+
* (`Events['internal/get']`, `@deepseek-ai/cordis` 4.0.2
|
|
7
|
+
* `lib/types/events.d.ts`): a listener wraps the `subagents` service VALUE on
|
|
8
|
+
* read and returns a prototype-delegating wrapper whose `start` /
|
|
9
|
+
* `startContinuable` resolve the dispatch role from the incoming request
|
|
10
|
+
* BEFORE delegating. The real service object is never mutated; readers of the
|
|
11
|
+
* `subagents` service transparently receive the wrapper.
|
|
12
|
+
*
|
|
13
|
+
* Role source (plan Global Constraints): the child request's prompt carries
|
|
14
|
+
* the Assignment header field `**Execute as**: <id>` — dsh role binding is
|
|
15
|
+
* prompt-only. {@link resolveDeclaredRoleFromAssignment} is the ONE pure
|
|
16
|
+
* resolution point; it canonicalizes exactly like the existing trimmed-id
|
|
17
|
+
* map (trim, drop ONE leading `@`, case-insensitive match against the
|
|
18
|
+
* declared trimmed ids) and returns the DECLARED RAW id. Undeclared, absent,
|
|
19
|
+
* `inherit`, or ambiguous declarations resolve to `undefined` = no role.
|
|
20
|
+
*
|
|
21
|
+
* The resolution reads the Assignment HEADER region only, never the whole
|
|
22
|
+
* prompt (Task 1 CF-2): the region ends at the first Assignment body marker,
|
|
23
|
+
* and when the prompt carries NO marker it is bounded to the LEADING HEADER
|
|
24
|
+
* BLOCK — the first paragraph plus every following paragraph that opens with
|
|
25
|
+
* header material, ending at the prompt's first prose paragraph. So a
|
|
26
|
+
* `**Execute as**: <id>` line quoted inside a body paragraph can never shape a
|
|
27
|
+
* resolution (it is outside the region) even though mstar's canonical dispatch
|
|
28
|
+
* text carries no body marker at all. Inside the region the engine's pinned
|
|
29
|
+
* ambiguity rule stands unchanged: the DISTINCT DECLARED ids must collapse to
|
|
30
|
+
* exactly one, else the dispatch is a no-op — never a first-occurrence pick.
|
|
31
|
+
*
|
|
32
|
+
* Per-child record: the wrapped start's RESULT carries the child session id —
|
|
33
|
+
* `start` resolves to a `SubagentRun` (`id` IS the published child session id
|
|
34
|
+
* for a local run) and `startContinuable` to a `ContinuableStart` (`childId`).
|
|
35
|
+
* The record `{ role, at, firstNoticePending }` is keyed by that id (Task 3
|
|
36
|
+
* reads it to emit the one role notice row). A result carrying only a
|
|
37
|
+
* `jobId` — a registry job id, NOT a session id — is never keyed: the child is
|
|
38
|
+
* correlated instead through the parent-owned `subagent/catalog` session event
|
|
39
|
+
* `{ childId, label }` joined on the delegating parent + request label.
|
|
40
|
+
*
|
|
41
|
+
* Persona delivery (plan Task 2): the SAME single resolution point merges the
|
|
42
|
+
* declared role's `roles.list[].persona` (trimmed, non-empty) into the
|
|
43
|
+
* request's NATIVE `persona` slot — the slot the runtime composes as the
|
|
44
|
+
* scoped `deployment:persona-prefix` system-prompt section on the child. The
|
|
45
|
+
* source is the RESOLVED ROLE only, so delivery is chain-independent: a
|
|
46
|
+
* `chain: []` role gets its persona exactly like a chained one (no routing
|
|
47
|
+
* state is read). Both native start surfaces are covered, with the gate each
|
|
48
|
+
* one's fail-loud contract needs (measured on the installed
|
|
49
|
+
* `@deepseek-ai/dsh-subagent` `0.1.5-rc.1`): the one-shot `start` REJECTS a
|
|
50
|
+
* request carrying `persona` for a provider whose
|
|
51
|
+
* `getProvider(name).capabilities.persona` is not `true` (`lib/index.js:3202-3227`,
|
|
52
|
+
* `assertCapabilities`), so the merge pre-checks that flag; the continuable
|
|
53
|
+
* surface is composed by the continuation manager, which applies
|
|
54
|
+
* `request.persona` unconditionally (`lib/index.js:1703-1705`) and is gated by
|
|
55
|
+
* the provider's `prepareContinuable` presence instead (`lib/index.js:3179-3183`).
|
|
56
|
+
* A gate miss skips the persona with ONE contained debug log — never a failed
|
|
57
|
+
* start. An explicit caller `persona` WINS (the slot is filled only when it is
|
|
58
|
+
* absent), and every skip path returns the caller's OWN request object, so the
|
|
59
|
+
* native call stays byte-identical.
|
|
60
|
+
*
|
|
61
|
+
* In-session notice row (plan Task 3): the SAME install point registers the
|
|
62
|
+
* `agent/pre-step` emitter (`./role-notice.ts`) over the record map it owns, so
|
|
63
|
+
* the role is announced once in the child's own session. Its per-agent
|
|
64
|
+
* `noticeEmitted` marker is exposed here for the caller's cleanup sites: the
|
|
65
|
+
* plugin dispose effect clears it, while `agent/disposed` deliberately KEEPS it
|
|
66
|
+
* (CF-6 — the marker is SESSION-stable and bounded, because a continuable
|
|
67
|
+
* child's durable session survives its activation).
|
|
68
|
+
*
|
|
69
|
+
* Durable role visibility (plan Task 3b): the SAME install point registers the
|
|
70
|
+
* host-side session projection unit (`./role-projection.ts`) that folds that
|
|
71
|
+
* notice row out of the child's own log, so the session-header badge can show
|
|
72
|
+
* the role for a settled child and after a host restart — a read of the SAME
|
|
73
|
+
* single write primitive, never a second one.
|
|
74
|
+
*
|
|
75
|
+
* Multi-fiber composition (CF-5): the record map and the notice marker are
|
|
76
|
+
* ROOT-shared, while only the `internal/get` wrapper (+ the catalog listener it
|
|
77
|
+
* feeds) is single-owner. Every applied fiber registers its OWN notice emitter
|
|
78
|
+
* and projection unit and reports its ownership structurally
|
|
79
|
+
* (`SubagentSeam.ownsWrapper`), so a disposing fiber cannot silently take the
|
|
80
|
+
* role surfaces down with it.
|
|
81
|
+
*
|
|
82
|
+
* Degrade-never-crash: an absent/reshaped service, an unexpected result
|
|
83
|
+
* shape, or any throwing bookkeeping degrades to the native path with at most
|
|
84
|
+
* ONE contained debug log — a dispatch is never affected.
|
|
85
|
+
*
|
|
86
|
+
* @module dsh-llm-fallbacks/subagents-seam
|
|
87
|
+
*/
|
|
88
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
89
|
+
import { type FallbacksRole } from './config.ts';
|
|
90
|
+
/**
|
|
91
|
+
* The cordis service-read waterfall event the seam registers on. Referenced
|
|
92
|
+
* ONCE (registration) so a future cordis rename is a one-line change here.
|
|
93
|
+
*/
|
|
94
|
+
export declare const SUBAGENT_SEAM_EVENT = "internal/get";
|
|
95
|
+
/** The service name the seam wraps (`ctx.subagents`). */
|
|
96
|
+
export declare const SUBAGENT_SEAM_SERVICE = "subagents";
|
|
97
|
+
/**
|
|
98
|
+
* One dispatch-resolved per-child record (Task 3 reads it to emit exactly one
|
|
99
|
+
* role notice row per child session).
|
|
100
|
+
*/
|
|
101
|
+
export interface SubagentSeamRecord {
|
|
102
|
+
/**
|
|
103
|
+
* The DECLARED RAW role id resolved at the dispatch seam.
|
|
104
|
+
*
|
|
105
|
+
* Authoritative-role precedence (CF-10, documented rather than resolved by
|
|
106
|
+
* re-branding): a `startContinuable` resume re-keys the child and may resolve
|
|
107
|
+
* a DIFFERENT declared role, and this field is then rewritten while the
|
|
108
|
+
* durable notice row the child's session already carries keeps the FIRST role.
|
|
109
|
+
* The projection deliberately reads the notice row, so the durable role is the
|
|
110
|
+
* child's announced identity and this record is only the LIVE dispatch hint
|
|
111
|
+
* (what the next start / the resume fallback would use). The two can therefore
|
|
112
|
+
* disagree after a re-dispatch until the child is disposed; no consumer may
|
|
113
|
+
* treat the record as the durable role.
|
|
114
|
+
*/
|
|
115
|
+
role: string;
|
|
116
|
+
/** When the record was written (epoch ms). */
|
|
117
|
+
at: number;
|
|
118
|
+
/**
|
|
119
|
+
* `true` until the child's role notice row has been emitted (Task 3). A
|
|
120
|
+
* later write for the SAME child session preserves an already-cleared
|
|
121
|
+
* marker, so a repeat dispatch/resume cannot produce a second notice row.
|
|
122
|
+
* The root-shared `noticeEmitted` set is the SESSION-stable half of that
|
|
123
|
+
* guarantee (it survives `agent/disposed`); this field is the per-record half
|
|
124
|
+
* that a resume rewrite preserves.
|
|
125
|
+
*/
|
|
126
|
+
firstNoticePending: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* `true` when the role DECLARES a persona and that persona was NOT delivered
|
|
129
|
+
* (Task 2's capability gate declined it, or no provider was registered to
|
|
130
|
+
* carry it). Absent when there was nothing to report: the persona was
|
|
131
|
+
* delivered, the caller set one (explicit intent wins), the role declares
|
|
132
|
+
* none, or no persona source is wired. Task 3 appends
|
|
133
|
+
* ` (persona not applied)` on `true` only.
|
|
134
|
+
*
|
|
135
|
+
* STICKY (Task 3 review Minor 4): a record rewrite (a `startContinuable`
|
|
136
|
+
* resume re-keys the child) can only ADD this verdict, never clear it. A
|
|
137
|
+
* resume recomputes the merge outcome on its own surface — and that surface
|
|
138
|
+
* legitimately returns `false` when it delivers the CALLER's persona or when
|
|
139
|
+
* its own merge degrades — but neither retroactively installs the role's
|
|
140
|
+
* DECLARED persona on the dispatch that already reported it missing. The
|
|
141
|
+
* child's one notice row is a claim about that dispatch, so the first `true`
|
|
142
|
+
* survives; a later `false` is simply not recorded.
|
|
143
|
+
*/
|
|
144
|
+
personaNotApplied?: boolean;
|
|
145
|
+
}
|
|
146
|
+
/** One consumed prompt content block (`@deepseek-ai/dsh-llm` `ContentBlock` text members). */
|
|
147
|
+
export interface SubagentPromptBlockView {
|
|
148
|
+
readonly type: string;
|
|
149
|
+
readonly text?: string;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Structural view of the one-shot start request (consumed fields only): the
|
|
153
|
+
* `prompt` is the role-extraction source, `label` + `parent` are the
|
|
154
|
+
* catalog-correlation join key, and `persona` is the NATIVE per-child persona
|
|
155
|
+
* slot the role persona is merged into (plan Global Constraints: an explicit
|
|
156
|
+
* caller value wins). The wrapper forwards the request object itself, so every
|
|
157
|
+
* other field reaches the service unchanged.
|
|
158
|
+
*/
|
|
159
|
+
export interface SubagentStartRequestView {
|
|
160
|
+
readonly prompt?: readonly SubagentPromptBlockView[];
|
|
161
|
+
readonly label?: string;
|
|
162
|
+
readonly parent?: {
|
|
163
|
+
readonly session?: {
|
|
164
|
+
readonly id?: string;
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
readonly persona?: string;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Structural view of the continuable start spec (consumed fields only):
|
|
171
|
+
* `childId` is the caller-reserved identity a resume already carries, used as
|
|
172
|
+
* the record fallback when the resume request has no Assignment header.
|
|
173
|
+
*/
|
|
174
|
+
export interface ContinuableStartSpecView {
|
|
175
|
+
readonly provider: string;
|
|
176
|
+
readonly label?: string;
|
|
177
|
+
readonly childId?: string;
|
|
178
|
+
readonly request: SubagentStartRequestView;
|
|
179
|
+
}
|
|
180
|
+
/** Options for {@link installSubagentSeam}. */
|
|
181
|
+
export interface SubagentSeamOptions {
|
|
182
|
+
/**
|
|
183
|
+
* Live declared-role id map (trimmed id → DECLARED RAW id), read per start
|
|
184
|
+
* so a settings change is observed without a re-install.
|
|
185
|
+
*/
|
|
186
|
+
roleIds: () => ReadonlyMap<string, string>;
|
|
187
|
+
/**
|
|
188
|
+
* Live declared roles (`roles.list`) — the persona source, read per start so
|
|
189
|
+
* a settings/persona edit is observed without a re-install. Absent ⇒ the
|
|
190
|
+
* seam only records roles (no persona delivery).
|
|
191
|
+
*/
|
|
192
|
+
roles?: () => readonly FallbacksRole[];
|
|
193
|
+
/** Contained debug sink (at most one line per contained degrade). */
|
|
194
|
+
debug?: (message: string) => void;
|
|
195
|
+
}
|
|
196
|
+
/** The installed seam (Task 3 surface + explicit teardown). */
|
|
197
|
+
export interface SubagentSeam {
|
|
198
|
+
/**
|
|
199
|
+
* Per-child records keyed by the child SESSION id (the wrapped start's
|
|
200
|
+
* result id). ROOT-shared (CF-5): every fiber applied over the same root reads
|
|
201
|
+
* the same map. Mutable by design: Task 3 clears `firstNoticePending` after
|
|
202
|
+
* emitting the notice.
|
|
203
|
+
*/
|
|
204
|
+
readonly records: Map<string, SubagentSeamRecord>;
|
|
205
|
+
/**
|
|
206
|
+
* Per-agent marker of children whose role notice row was already emitted
|
|
207
|
+
* (mirrors the runtime's `dispatchInjected`): the in-memory half of Task 3's
|
|
208
|
+
* once-per-child guarantee, also ROOT-shared. SESSION-stable (CF-6): it is
|
|
209
|
+
* deliberately NOT cleared on `agent/disposed` — a continuable child's durable
|
|
210
|
+
* session survives the activation, so clearing the marker would let a
|
|
211
|
+
* re-activation resume append a second notice row to the same log. It is
|
|
212
|
+
* bounded (`NOTICE_EMITTED_LIMIT`) and cleared only in the plugin dispose
|
|
213
|
+
* effect.
|
|
214
|
+
*/
|
|
215
|
+
readonly noticeEmitted: Set<string>;
|
|
216
|
+
/**
|
|
217
|
+
* `true` when THIS install owns the root-scoped `internal/get` wrapper (plus
|
|
218
|
+
* the catalog-correlation listener it feeds) — the only part that must have a
|
|
219
|
+
* single owner. `false` on a later fiber over the same root, which still
|
|
220
|
+
* registers its OWN notice emitter and projection unit. The callers' dedupe
|
|
221
|
+
* branch reads THIS field (CF-5): a structured marker, never a thrown
|
|
222
|
+
* message.
|
|
223
|
+
*/
|
|
224
|
+
readonly ownsWrapper: boolean;
|
|
225
|
+
/** Stop intercepting service reads (the owning fiber's teardown also does). */
|
|
226
|
+
dispose(): void;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Cap on un-correlated claims (a `jobId`-only result whose catalog event never
|
|
230
|
+
* arrives). Bounded insertion-order eviction — recency-ordered, because a
|
|
231
|
+
* re-claimed hot key is re-inserted (`beginCorrelation` deletes before it sets),
|
|
232
|
+
* so the busiest claim can never be the one evicted.
|
|
233
|
+
*
|
|
234
|
+
* @internal Exported for the bound test (mirrors `subagentSeamOf`): the value is
|
|
235
|
+
* not part of the plugin's public surface, but a test that hardcoded 64 could
|
|
236
|
+
* not tell the real bound from a stale copy.
|
|
237
|
+
*/
|
|
238
|
+
export declare const PENDING_CORRELATION_LIMIT = 64;
|
|
239
|
+
/**
|
|
240
|
+
* PURE dispatch-role resolution (plan Global Constraints): read the Assignment
|
|
241
|
+
* header field `**Execute as**: <id>` from `prompt` and return the DECLARED
|
|
242
|
+
* RAW role id, or `undefined` when nothing resolves. Never throws.
|
|
243
|
+
*
|
|
244
|
+
* - absent / empty / body-quoted-only → `undefined`
|
|
245
|
+
* - `inherit` (the reserved "no specific role" id) → `undefined`
|
|
246
|
+
* - undeclared id → `undefined` (never invent a role)
|
|
247
|
+
* - several DISTINCT declared ids in the header region → `undefined`
|
|
248
|
+
* (ambiguous); repeats of the same declared id (e.g. `coder` and `@CODER`)
|
|
249
|
+
* still resolve
|
|
250
|
+
*
|
|
251
|
+
* @param prompt - the Assignment text carried by the child request's prompt.
|
|
252
|
+
* @param roleIds - the declared trimmed-id map (`trimmed id → declared raw id`).
|
|
253
|
+
*/
|
|
254
|
+
export declare function resolveDeclaredRoleFromAssignment(prompt: string, roleIds: ReadonlyMap<string, string>): string | undefined;
|
|
255
|
+
/**
|
|
256
|
+
* PURE, chain-independent persona lookup (plan Task 2 / Global Constraints):
|
|
257
|
+
* the DECLARED `persona` of `roleId`, trimmed, and only when it is still
|
|
258
|
+
* non-empty after the trim — a blank persona is NO persona. `roleId` is the
|
|
259
|
+
* DECLARED RAW id the seam already resolved, so this is an exact id match, not
|
|
260
|
+
* a second canonicalization pass.
|
|
261
|
+
*
|
|
262
|
+
* Only `id` and `persona` are read: no `chain`, no `rootChain`, no routing
|
|
263
|
+
* state — a `chain: []` role's persona resolves exactly like a chained one's.
|
|
264
|
+
*
|
|
265
|
+
* @param roles - the live declared roles (`roles.list`).
|
|
266
|
+
* @param roleId - the DECLARED RAW role id resolved at the seam.
|
|
267
|
+
*/
|
|
268
|
+
export declare function personaForRole(roles: readonly FallbacksRole[], roleId: string): string | undefined;
|
|
269
|
+
/**
|
|
270
|
+
* @internal Test seam (mirrors `chainHeads`): the seam installed on
|
|
271
|
+
* `ctx`, if any. Not part of the plugin's public surface — lets tests read and
|
|
272
|
+
* seed the per-child record map without reaching into the installation
|
|
273
|
+
* closure. `undefined` when no seam is installed.
|
|
274
|
+
*/
|
|
275
|
+
export declare function subagentSeamOf(ctx: Context): SubagentSeam | undefined;
|
|
276
|
+
/**
|
|
277
|
+
* Install the dispatch seam on `ctx`: wrap every `subagents` service read and
|
|
278
|
+
* resolve + record the dispatch role per start, and register this fiber's role
|
|
279
|
+
* surfaces (the notice emitter + the projection unit).
|
|
280
|
+
*
|
|
281
|
+
* Never throws for a multi-fiber apply (CF-5): a fiber whose root already owns
|
|
282
|
+
* the `internal/get` wrapper gets a seam with `ownsWrapper: false` — it shares
|
|
283
|
+
* the root's record map / notice marker (so exactly one notice row is written
|
|
284
|
+
* per child) and still installs its OWN notice emitter and projection unit, so
|
|
285
|
+
* neither surface dies when another fiber of the same root disposes.
|
|
286
|
+
*
|
|
287
|
+
* Returns the seam (the shared record map Task 3 reads, the ownership marker,
|
|
288
|
+
* plus an idempotent `dispose` that withdraws THIS fiber's registrations). The
|
|
289
|
+
* `internal/get` listener is owned by this module; callers that rely on the
|
|
290
|
+
* fiber teardown alone may ignore `dispose`.
|
|
291
|
+
*/
|
|
292
|
+
export declare function installSubagentSeam(ctx: Context, options: SubagentSeamOptions): SubagentSeam;
|