@rebon/cli-darwin-arm64 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/compose-runtime/package.json +17 -0
- package/bin/compose-runtime/payload/compose/credentials-runtime.js +38 -0
- package/bin/compose-runtime/payload/compose/llm-runtime.js +330 -0
- package/bin/compose-runtime/payload/compose/loop-assembly.js +149 -0
- package/bin/compose-runtime/payload/compose/serve-dispatch.js +82 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-anonymous-user-id.js +10 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-credentials.js +13 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-launch-environment.js +21 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-llm.js +313 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-settings.js +43 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-tools.js +20 -0
- package/bin/compose-runtime/payload/compose/shims/dsh-web.js +10 -0
- package/bin/compose-runtime/payload/compose/shims/llm-adapter.js +32 -0
- package/bin/compose-runtime/payload/compose/shims/zod-lite.js +49 -0
- package/bin/compose-runtime/payload/compose/systemprompt-runtime.js +74 -0
- package/bin/compose-runtime/payload/compose/tools-runtime.js +228 -0
- package/bin/compose-runtime/payload/compose/web-runtime.js +204 -0
- package/bin/compose-runtime/payload/vendor/cordis/index.js +1530 -0
- package/bin/compose-runtime/payload/vendor/cosmokit/LICENSE +21 -0
- package/bin/compose-runtime/payload/vendor/cosmokit/index.mjs +357 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-agent-loop +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-core +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-llm-deepseek +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-logger-console +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-scope +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-session +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-system-prompt +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timeout +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-timer +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-todo +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tool-web +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-tools-schema +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/LICENSE-web-search-exa +21 -0
- package/bin/compose-runtime/payload/vendor/dsh/agent-loop.js +1193 -0
- package/bin/compose-runtime/payload/vendor/dsh/agent.js +714 -0
- package/bin/compose-runtime/payload/vendor/dsh/llm-core.js +269 -0
- package/bin/compose-runtime/payload/vendor/dsh/llm-deepseek.js +672 -0
- package/bin/compose-runtime/payload/vendor/dsh/logger-console.js +83 -0
- package/bin/compose-runtime/payload/vendor/dsh/scope.js +287 -0
- package/bin/compose-runtime/payload/vendor/dsh/session.js +1668 -0
- package/bin/compose-runtime/payload/vendor/dsh/system-prompt.js +309 -0
- package/bin/compose-runtime/payload/vendor/dsh/timeout.js +100 -0
- package/bin/compose-runtime/payload/vendor/dsh/timer.js +128 -0
- package/bin/compose-runtime/payload/vendor/dsh/tool-todo.js +143 -0
- package/bin/compose-runtime/payload/vendor/dsh/tool-web.js +1527 -0
- package/bin/compose-runtime/payload/vendor/dsh/tools-schema.js +849 -0
- package/bin/compose-runtime/payload/vendor/dsh/web-search-exa.js +122 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/LICENSE +21 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/index.js +177 -0
- package/bin/compose-runtime/payload/vendor/eventsource-parser/stream.js +48 -0
- package/bin/compose-runtime/payload/vendor/schemastery/index.mjs +656 -0
- package/bin/compose-runtime/payload-manifests.json +48 -0
- package/bin/compose-runtime/src/bridge.mjs +142 -0
- package/bin/compose-runtime/src/credentials-runtime.mjs +50 -0
- package/bin/compose-runtime/src/eventsource-stream.mjs +48 -0
- package/bin/compose-runtime/src/index.mjs +7 -0
- package/bin/compose-runtime/src/llm-runtime.mjs +326 -0
- package/bin/compose-runtime/src/loader.mjs +78 -0
- package/bin/compose-runtime/src/loop-assembly.mjs +237 -0
- package/bin/compose-runtime/src/payload.mjs +49 -0
- package/bin/compose-runtime/src/plugin.mjs +59 -0
- package/bin/compose-runtime/src/realm.mjs +223 -0
- package/bin/compose-runtime/src/registry.mjs +191 -0
- package/bin/compose-runtime/src/resolve.mjs +115 -0
- package/bin/compose-runtime/src/systemprompt-runtime.mjs +67 -0
- package/bin/compose-runtime/src/tools-runtime.mjs +239 -0
- package/bin/compose-runtime/src/web-runtime.mjs +209 -0
- package/bin/plugin-host/package.json +11 -0
- package/bin/plugin-host/src/bridge.mjs +64 -0
- package/bin/plugin-host/src/cli.mjs +105 -0
- package/bin/plugin-host/src/framing.mjs +113 -0
- package/bin/plugin-host/src/host.mjs +624 -0
- package/bin/plugin-host/src/json.mjs +196 -0
- package/bin/plugin-host/src/lifecycle.mjs +114 -0
- package/bin/plugin-host/src/loader.mjs +142 -0
- package/bin/plugin-host/src/methods.mjs +256 -0
- package/bin/plugin-host/src/protocol.mjs +129 -0
- package/bin/plugin-host/src/sdk.mjs +7 -0
- package/bin/rebon +0 -0
- package/bin/rebon-boa-helper +0 -0
- package/package.json +4 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// What a plugin package *is*, when the composition is in the picture.
|
|
2
|
+
//
|
|
3
|
+
// The host knows one shape: a module exporting `activate`, which registers
|
|
4
|
+
// through the api it is handed. A Cordis plugin knows nothing about any of
|
|
5
|
+
// that — it exports `apply(ctx, config)` and registers by calling services on
|
|
6
|
+
// a context it shares with every other entry. Both are plugins on the plane;
|
|
7
|
+
// they differ only in what "loading" means, which is exactly the question the
|
|
8
|
+
// host delegates.
|
|
9
|
+
//
|
|
10
|
+
// The rule, in order, and deliberately not a heuristic — a package's shape is
|
|
11
|
+
// something its author decides, so it should be decidable by reading it:
|
|
12
|
+
//
|
|
13
|
+
// 1. `activate` is a function → the host's own loader
|
|
14
|
+
// 2. `apply` is the module's own function → a Cordis plugin (the module)
|
|
15
|
+
// 3. `default` is an object with `apply` → a Cordis plugin
|
|
16
|
+
// 4. `default` is a function → a Cordis functional plugin
|
|
17
|
+
// 5. anything else → the host's own loader, which
|
|
18
|
+
// refuses it by name
|
|
19
|
+
//
|
|
20
|
+
// Step 1 comes first so a rebon-native plugin is never mistaken for a Cordis
|
|
21
|
+
// one, and step 4 is last so the ambiguous case (a bare default function) goes
|
|
22
|
+
// to the composition rather than being guessed at from argument counts.
|
|
23
|
+
import { resolveEntry } from '../../plugin-host/src/loader.mjs';
|
|
24
|
+
|
|
25
|
+
const own = (value, key) => value != null && Object.prototype.hasOwnProperty.call(value, key);
|
|
26
|
+
|
|
27
|
+
class LoaderError extends Error {
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = 'LoaderError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// The Cordis plugin a module holds, or undefined if it does not hold one.
|
|
36
|
+
export function cordisPluginOf(module) {
|
|
37
|
+
if (typeof module?.activate === 'function') return undefined;
|
|
38
|
+
if (own(module, 'apply') && typeof module.apply === 'function') return module;
|
|
39
|
+
const fallback = module?.default;
|
|
40
|
+
if (fallback && typeof fallback === 'object' && typeof fallback.apply === 'function') return fallback;
|
|
41
|
+
if (typeof fallback === 'function') return fallback;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Builds the load/unload seams the host runs with.
|
|
46
|
+
///
|
|
47
|
+
/// `next` is the host's own loader, handed back untouched for anything this
|
|
48
|
+
/// module does not recognise — an adapter that reimplemented it would be a
|
|
49
|
+
/// second set of rules for the same shape.
|
|
50
|
+
export async function createLoader({ next }) {
|
|
51
|
+
return {
|
|
52
|
+
async load(request) {
|
|
53
|
+
const url = resolveEntry(request.root, request.entry);
|
|
54
|
+
let module;
|
|
55
|
+
try {
|
|
56
|
+
module = await import(url);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new LoaderError('[ENTRY_FAILED]', `plugin entry ${request.entry} failed to load: ${cause?.message ?? cause}`);
|
|
59
|
+
}
|
|
60
|
+
const plugin = cordisPluginOf(module);
|
|
61
|
+
// Importing is idempotent, so handing the already-imported module back to
|
|
62
|
+
// the host's loader costs nothing and keeps one import per entry — which
|
|
63
|
+
// matters, because a module evaluated twice would register twice.
|
|
64
|
+
if (plugin === undefined) return next(request, async () => module);
|
|
65
|
+
|
|
66
|
+
// Imported lazily: this module is loaded by the host at startup, long
|
|
67
|
+
// before module resolution for the payload has been installed.
|
|
68
|
+
const { mountEntry } = await import('./realm.mjs');
|
|
69
|
+
const sink = await mountEntry(request, plugin);
|
|
70
|
+
return sink.loaded();
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async unload(pluginId) {
|
|
74
|
+
const { disposeEntry } = await import('./realm.mjs');
|
|
75
|
+
await disposeEntry(pluginId);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The composition-side glue that lets the REAL dsh agent loop drive a turn
|
|
2
|
+
// inside the composition realm.
|
|
3
|
+
//
|
|
4
|
+
// Ported from `js/compose/loop-assembly.js`. Three duties, all rebon-owned
|
|
5
|
+
// (the dsh packages stay unmodified):
|
|
6
|
+
//
|
|
7
|
+
// 1. **Tool-schema supply** — a dsh systemPrompt tool provider merging the
|
|
8
|
+
// composition's own registered definitions with rebon's core tool catalog,
|
|
9
|
+
// so what the model is offered and what the scheduler can dispatch stay one
|
|
10
|
+
// fact (a local definition shadows a core tool name in both places).
|
|
11
|
+
// 2. **Observability** — every dsh `session/event` (chunks included: the
|
|
12
|
+
// embedder's session face streams from them) and `agent/error` is published
|
|
13
|
+
// onto rebon's event plane.
|
|
14
|
+
// 3. **Drive** — `loop:control` is the embedder's inbound face
|
|
15
|
+
// (followup / steer / cancel / status).
|
|
16
|
+
//
|
|
17
|
+
// Two things moved with the transport:
|
|
18
|
+
//
|
|
19
|
+
// * **The catalog is configuration, not a call.** It used to be
|
|
20
|
+
// `describeTools()`, an op straight into rebon. There is no such method on
|
|
21
|
+
// the plane, and there should not be: what rebon offers a model is settled
|
|
22
|
+
// when the composition is built, so rebon hands it over at load. A snapshot
|
|
23
|
+
// either way — the old op was read once during `apply` too.
|
|
24
|
+
// * **Events publish through a scope handle.** A loop produces turn events on
|
|
25
|
+
// its own schedule, with no inbound call to hang them on, so the assembly
|
|
26
|
+
// takes a handle on each open session and fans its events to whichever are
|
|
27
|
+
// live. A loop exists before any session opens, so the first events are
|
|
28
|
+
// held — boundedly — until one does, and the self-drive kickoff waits for
|
|
29
|
+
// the same moment: an agent that ran a whole turn before anyone was
|
|
30
|
+
// listening would be a turn rebon never saw.
|
|
31
|
+
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm';
|
|
32
|
+
import { logger } from './bridge.mjs';
|
|
33
|
+
import { sinkOf } from './registry.mjs';
|
|
34
|
+
|
|
35
|
+
export const name = 'rebon-loop-assembly';
|
|
36
|
+
export const inject = ['systemPrompt', 'tools'];
|
|
37
|
+
|
|
38
|
+
/** Topics this assembly publishes; a loop plugin declares exactly these. */
|
|
39
|
+
export const LOOP_TOPICS = Object.freeze([
|
|
40
|
+
'loop:event',
|
|
41
|
+
'loop:agent-error',
|
|
42
|
+
'loop:agent-created',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export async function apply(ctx, config = {}) {
|
|
46
|
+
const sink = sinkOf(ctx);
|
|
47
|
+
if (sink === undefined) {
|
|
48
|
+
throw new Error('rebon-loop-assembly must be mounted as a composition entry');
|
|
49
|
+
}
|
|
50
|
+
// Embedder-chosen discriminator: with several loops in one composition, every
|
|
51
|
+
// published event carries the owning loop's tag so subscribers can tell them
|
|
52
|
+
// apart.
|
|
53
|
+
const tag = typeof config.tag === 'string' && config.tag.length > 0 ? config.tag : undefined;
|
|
54
|
+
const stamp = (payload) => (tag === undefined ? payload : { tag, ...payload });
|
|
55
|
+
|
|
56
|
+
// Sessions this loop is attached to. A published event goes to all of them;
|
|
57
|
+
// in the ordinary case there is exactly one.
|
|
58
|
+
const sessions = new Set();
|
|
59
|
+
// A loop starts existing when it is loaded, and a session opens after — so
|
|
60
|
+
// the first events (an agent being created, above all) happen with nobody
|
|
61
|
+
// listening. Holding them until the first session attaches is the difference
|
|
62
|
+
// between "rebon saw the loop start" and "rebon saw the loop already
|
|
63
|
+
// running"; the bound is here so a loop nobody ever attaches to cannot grow
|
|
64
|
+
// without limit, and passing it is said out loud rather than swallowed.
|
|
65
|
+
const PRE_ATTACH_LIMIT = 256;
|
|
66
|
+
let attached = false;
|
|
67
|
+
let dropped = 0;
|
|
68
|
+
const held = [];
|
|
69
|
+
const deliver = (session, topic, payload) => {
|
|
70
|
+
// Best effort, and never a reason for the loop to fail: an event nobody
|
|
71
|
+
// could be told about is not a turn that went wrong.
|
|
72
|
+
void session.publish(topic, payload).catch((cause) => {
|
|
73
|
+
logger.warn(`loop-assembly: publishing ${topic} failed: ${cause?.message ?? cause}`);
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
sink.scope((scopeCtx) => {
|
|
77
|
+
sessions.add(scopeCtx);
|
|
78
|
+
if (!attached) {
|
|
79
|
+
attached = true;
|
|
80
|
+
if (dropped > 0) {
|
|
81
|
+
logger.warn(`loop-assembly: dropped ${dropped} event(s) produced before any session attached`);
|
|
82
|
+
}
|
|
83
|
+
for (const [topic, payload] of held.splice(0)) deliver(scopeCtx, topic, payload);
|
|
84
|
+
}
|
|
85
|
+
kickoff();
|
|
86
|
+
return () => sessions.delete(scopeCtx);
|
|
87
|
+
});
|
|
88
|
+
const announce = (topic, payload) => {
|
|
89
|
+
const stamped = stamp(payload);
|
|
90
|
+
if (!attached) {
|
|
91
|
+
if (held.length >= PRE_ATTACH_LIMIT) {
|
|
92
|
+
held.shift();
|
|
93
|
+
dropped += 1;
|
|
94
|
+
}
|
|
95
|
+
held.push([topic, stamped]);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const session of sessions) deliver(session, topic, stamped);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Optional prompt contribution into the loop realm's REAL dsh systemPrompt
|
|
102
|
+
// (the loop's own prompt plane, not rebon's seat — `isolate` keeps them
|
|
103
|
+
// apart, which is why the composition structure declares it).
|
|
104
|
+
if (config.section !== undefined && config.section !== null && typeof config.section === 'object') {
|
|
105
|
+
ctx.systemPrompt.section(config.section);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const seatSchemas = (Array.isArray(config.toolCatalog) ? config.toolCatalog : []).map((tool) => ({
|
|
109
|
+
name: String(tool?.name ?? ''),
|
|
110
|
+
description: String(tool?.description ?? ''),
|
|
111
|
+
parameters: tool?.inputSchema ?? tool?.parameters ?? { type: 'object' },
|
|
112
|
+
})).filter((tool) => tool.name.length > 0);
|
|
113
|
+
|
|
114
|
+
ctx.systemPrompt.tools(() => {
|
|
115
|
+
const merged = new Map();
|
|
116
|
+
for (const schema of seatSchemas) merged.set(schema.name, schema);
|
|
117
|
+
for (const [toolName, def] of ctx.tools.defs) {
|
|
118
|
+
merged.set(toolName, {
|
|
119
|
+
name: toolName,
|
|
120
|
+
description: String(def.description ?? ''),
|
|
121
|
+
parameters: def.parameters ?? { type: 'object' },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { schemas: [...merged.values()] };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Full-fidelity relay: the dsh session log IS the loop's truth, and the
|
|
128
|
+
// embedder's session face (streaming UI, transcript projection) needs every
|
|
129
|
+
// event — chunks included.
|
|
130
|
+
ctx.on('session/event', (session, event) => {
|
|
131
|
+
announce('loop:event', {
|
|
132
|
+
sessionId: String(session.id),
|
|
133
|
+
seq: event.seq,
|
|
134
|
+
type: event.type,
|
|
135
|
+
data: event.data ?? null,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
ctx.on('agent/error', (payload) => {
|
|
140
|
+
announce('loop:agent-error', {
|
|
141
|
+
agentId: String(payload.agent?.id ?? ''),
|
|
142
|
+
turn: payload.turn,
|
|
143
|
+
step: payload.step,
|
|
144
|
+
error: errorChain(payload.error),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Which agents are this loop's.
|
|
149
|
+
//
|
|
150
|
+
// The dsh `agents` registry is one per composition realm, so with more than
|
|
151
|
+
// one loop mounted every assembly hears every agent being created. Naming the
|
|
152
|
+
// configured ids is how an assembly knows which of them it is responsible
|
|
153
|
+
// for; dsh appends a session suffix to the configured id, so the match is on
|
|
154
|
+
// that stem. With no list the assembly adopts everything, which is the
|
|
155
|
+
// single-loop composition and the honest default for it.
|
|
156
|
+
const owned = Array.isArray(config.agents)
|
|
157
|
+
? config.agents.map(String).filter((id) => id.length > 0)
|
|
158
|
+
: undefined;
|
|
159
|
+
const isMine = (agentId) => owned === undefined
|
|
160
|
+
|| owned.some((id) => agentId === id || agentId.startsWith(`${id}-`));
|
|
161
|
+
|
|
162
|
+
const live = new Map();
|
|
163
|
+
const kicked = new Set();
|
|
164
|
+
// The self-drive convenience, deliberately tied to a session rather than to
|
|
165
|
+
// an agent appearing: an agent created before anyone is listening would run a
|
|
166
|
+
// whole turn whose events had nowhere to go.
|
|
167
|
+
function kickoff() {
|
|
168
|
+
const text = config.kickoff;
|
|
169
|
+
if (typeof text !== 'string' || text.length === 0) return;
|
|
170
|
+
if (!attached) return;
|
|
171
|
+
for (const [id, agent] of live) {
|
|
172
|
+
if (kicked.has(id)) continue;
|
|
173
|
+
kicked.add(id);
|
|
174
|
+
agent.followup(createUserMessage({
|
|
175
|
+
content: [{ type: 'text', text }],
|
|
176
|
+
source: { kind: 'user' },
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
181
|
+
const agentId = String(agent.id);
|
|
182
|
+
if (!isMine(agentId)) return;
|
|
183
|
+
live.set(agentId, agent);
|
|
184
|
+
announce('loop:agent-created', { agentId });
|
|
185
|
+
kickoff();
|
|
186
|
+
});
|
|
187
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
188
|
+
live.delete(String(agent.id));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Session events carry no agent, so an assembly cannot tell whose turn it is
|
|
192
|
+
// from the event alone. Ownership of the *session* is the same question one
|
|
193
|
+
// step earlier: a session belongs to whichever agent is running in it.
|
|
194
|
+
const ownedSessions = new Set();
|
|
195
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
196
|
+
if (isMine(String(agent.id)) && agent.session?.id !== undefined) {
|
|
197
|
+
ownedSessions.add(String(agent.session.id));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Inbound control face: the embedder drives the loop with ordinary service
|
|
202
|
+
// calls. Commands address the single configured agent by default, or a
|
|
203
|
+
// specific one via `agentId`.
|
|
204
|
+
const resolveAgent = (input) => {
|
|
205
|
+
if (typeof input?.agentId === 'string') return live.get(input.agentId);
|
|
206
|
+
if (live.size === 1) return live.values().next().value;
|
|
207
|
+
return undefined;
|
|
208
|
+
};
|
|
209
|
+
const userMessage = (text) => createUserMessage({
|
|
210
|
+
content: [{ type: 'text', text: String(text) }],
|
|
211
|
+
source: { kind: 'user' },
|
|
212
|
+
});
|
|
213
|
+
sink.service('loop:control', (input) => {
|
|
214
|
+
const agent = resolveAgent(input);
|
|
215
|
+
if (agent === undefined) {
|
|
216
|
+
const known = [...live.keys()];
|
|
217
|
+
throw new Error(`loop:control: no live agent${input?.agentId ? ` "${input.agentId}"` : ''} (live: ${known.join(', ') || '(none)'})`);
|
|
218
|
+
}
|
|
219
|
+
switch (input?.kind) {
|
|
220
|
+
case 'followup':
|
|
221
|
+
agent.followup(userMessage(input.text ?? ''));
|
|
222
|
+
return { accepted: true, agentId: String(agent.id) };
|
|
223
|
+
case 'steer':
|
|
224
|
+
agent.steer(userMessage(input.text ?? ''));
|
|
225
|
+
return { accepted: true, agentId: String(agent.id) };
|
|
226
|
+
case 'cancel':
|
|
227
|
+
agent.cancel({ kind: 'user' });
|
|
228
|
+
return { accepted: true, agentId: String(agent.id) };
|
|
229
|
+
case 'status':
|
|
230
|
+
return { agentId: String(agent.id), status: agent.status };
|
|
231
|
+
default:
|
|
232
|
+
throw new Error(`loop:control: unknown command kind ${JSON.stringify(input?.kind)}`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default { name, inject, apply };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Where the vendored JS payload lives.
|
|
2
|
+
//
|
|
3
|
+
// The payload — Cordis, cosmokit, schemastery and the dsh package set — is this
|
|
4
|
+
// package's own, in `payload/`. It used to be owned by `crates/rebon-kernel-js`
|
|
5
|
+
// and pointed at from here, because the deno_core host was what shipped and two
|
|
6
|
+
// copies of a 600KB vendored tree would diverge. V8 is gone, so the tree moved
|
|
7
|
+
// next to its runtime and this file is the only one that changed.
|
|
8
|
+
//
|
|
9
|
+
// `vendor/cordis/index.js` is the sentinel, for the same reason `bridge.js`
|
|
10
|
+
// is on the Rust side: every consumer loads it, so a directory without it is
|
|
11
|
+
// not a payload.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const SENTINEL = path.join('vendor', 'cordis', 'index.js');
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const holdsPayload = (dir) => fs.existsSync(path.join(dir, SENTINEL));
|
|
20
|
+
|
|
21
|
+
/// The payload directory, or a refusal naming what was tried.
|
|
22
|
+
///
|
|
23
|
+
/// A ladder, not a search — the same rule `REBON_PLUGIN_NODE` follows on the
|
|
24
|
+
/// Rust side. `REBON_KERNEL_JS_DIR` is an explicit demand: if what it names
|
|
25
|
+
/// does not hold a payload, that is the answer, not a reason to go looking
|
|
26
|
+
/// somewhere else. Falling back would run the composition against a payload
|
|
27
|
+
/// the operator did not choose and never told them.
|
|
28
|
+
///
|
|
29
|
+
/// Without the override there is one place it can be: `payload/` inside this
|
|
30
|
+
/// package, in a checkout and in a deployment alike.
|
|
31
|
+
///
|
|
32
|
+
/// Throws rather than returning a guess, because every module specifier the
|
|
33
|
+
/// composition resolves is built from this path — a wrong one surfaces later
|
|
34
|
+
/// as two dozen unrelated "cannot find module" errors.
|
|
35
|
+
export function payloadDir() {
|
|
36
|
+
const override = process.env.REBON_KERNEL_JS_DIR;
|
|
37
|
+
if (override) {
|
|
38
|
+
if (holdsPayload(override)) return path.resolve(override);
|
|
39
|
+
throw new Error(
|
|
40
|
+
`[NO_PAYLOAD] REBON_KERNEL_JS_DIR names ${override}, which does not hold ${SENTINEL}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const beside = path.join(here, '..', 'payload');
|
|
44
|
+
if (holdsPayload(beside)) return path.resolve(beside);
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[NO_PAYLOAD] no vendored JS payload found (looked for ${SENTINEL} under ${beside}); `
|
|
47
|
+
+ 'set REBON_KERNEL_JS_DIR to the directory holding it',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The composition control plugin.
|
|
2
|
+
//
|
|
3
|
+
// rebon loads this one first, and everything else the composition contains is
|
|
4
|
+
// loaded after it, as an ordinary `plugin/load` each. What this one does is
|
|
5
|
+
// make that possible: install module resolution for the vendored payload,
|
|
6
|
+
// create the shared Cordis realm the entries mount into, and answer for the
|
|
7
|
+
// facts the protocol has no field for.
|
|
8
|
+
//
|
|
9
|
+
// **Why the structure lives in this plugin's config.** Groups and `isolate` are
|
|
10
|
+
// composition structure, not plugin facts: a group is a container that is not a
|
|
11
|
+
// module, and an isolated realm is a relationship between entries. Neither can
|
|
12
|
+
// be said in one entry's own load request, so rebon says it once, here, naming
|
|
13
|
+
// only ids and placement. Each entry's `plugin/load` then carries its module
|
|
14
|
+
// and its own configuration.
|
|
15
|
+
//
|
|
16
|
+
// Module resolution has to be installed before Cordis is imported, and static
|
|
17
|
+
// imports are linked before a module body runs — which is why everything below
|
|
18
|
+
// is imported dynamically. Only Node's own builtins, which need no resolution,
|
|
19
|
+
// could be static, and none are needed.
|
|
20
|
+
export async function activate(api, config = {}) {
|
|
21
|
+
const { installModuleResolution } = await import('./resolve.mjs');
|
|
22
|
+
installModuleResolution({
|
|
23
|
+
...(config.payloadDir ? { dir: config.payloadDir } : {}),
|
|
24
|
+
extraModules: config.modules ?? {},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const { withCall } = await import('./bridge.mjs');
|
|
28
|
+
const { builtinSeats, createRealm, mounted, reportFor } = await import('./realm.mjs');
|
|
29
|
+
await createRealm({ web: config.web ?? null, entries: config.entries ?? [] });
|
|
30
|
+
|
|
31
|
+
// Registered before any entry is loaded, so an entry claiming the same
|
|
32
|
+
// service name hits the registrar's refusal rather than replacing this.
|
|
33
|
+
//
|
|
34
|
+
// What it answers is the part of a load report the protocol cannot carry: a
|
|
35
|
+
// route's model catalog, a prompt section, a web provider. Those belong to
|
|
36
|
+
// one plugin kind, and the ready report is a shape every plugin shares — so
|
|
37
|
+
// they are asked for rather than bolted on.
|
|
38
|
+
api.service('compose', async (request, ctx) => withCall(ctx, async () => {
|
|
39
|
+
const kind = request?.kind;
|
|
40
|
+
if (kind === 'list') {
|
|
41
|
+
return { plugins: mounted(), builtin: builtinSeats() };
|
|
42
|
+
}
|
|
43
|
+
if (kind === 'report') {
|
|
44
|
+
const pluginId = String(request?.pluginId ?? '');
|
|
45
|
+
const report = reportFor(pluginId);
|
|
46
|
+
if (report === undefined) {
|
|
47
|
+
const error = new Error(`no composition entry ${JSON.stringify(pluginId)} is mounted`);
|
|
48
|
+
error.code = '[UNKNOWN_PLUGIN]';
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return report;
|
|
52
|
+
}
|
|
53
|
+
const error = new Error(`compose does not answer kind ${JSON.stringify(kind ?? null)}`);
|
|
54
|
+
error.code = '[UNKNOWN_CONTROL]';
|
|
55
|
+
throw error;
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default activate;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The composition realm: one Cordis context, shared by every entry.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole reason the composition exists rather than each plugin
|
|
4
|
+
// living alone. Cordis plugins reach each other through services on a context —
|
|
5
|
+
// `inject: ['tools']`, `ctx.llm.registerAdapter(...)` — and that only works if
|
|
6
|
+
// they are mounted on the same one. So the plane's "one plugin, one load" is
|
|
7
|
+
// the outside view, and inside the host they all land here.
|
|
8
|
+
//
|
|
9
|
+
// Two things live here that a flat list of plugins cannot express, and both are
|
|
10
|
+
// composition structure rather than plugin facts, which is why rebon describes
|
|
11
|
+
// them once when the composition is created rather than per load:
|
|
12
|
+
//
|
|
13
|
+
// * **groups** — a container that is not a module. Unloading one is unloading
|
|
14
|
+
// its members, which rebon does by naming them; nothing is loaded for the
|
|
15
|
+
// group itself.
|
|
16
|
+
// * **isolate** — Cordis's own realm narrowing. The agent loop needs its own
|
|
17
|
+
// `systemPrompt` because the real dsh service and rebon's seat share that
|
|
18
|
+
// name; without isolation one of them loses.
|
|
19
|
+
//
|
|
20
|
+
// Ordering note: this module imports Cordis by bare specifier, so it must not
|
|
21
|
+
// be imported before `installModuleResolution()` has run. Everything that
|
|
22
|
+
// reaches it does so through a dynamic import for exactly that reason.
|
|
23
|
+
import { Context } from 'cordis';
|
|
24
|
+
import { OWNER, RegistrationSink, SINK } from './registry.mjs';
|
|
25
|
+
import RebonLlmRuntime from './llm-runtime.mjs';
|
|
26
|
+
import RebonCredentials from './credentials-runtime.mjs';
|
|
27
|
+
import RebonToolsRuntime from './tools-runtime.mjs';
|
|
28
|
+
import RebonWebRuntime from './web-runtime.mjs';
|
|
29
|
+
import RebonSystemPromptRuntime from './systemprompt-runtime.mjs';
|
|
30
|
+
|
|
31
|
+
/** The built-in seats, in mount order: a dsh `inject` is satisfied at mount
|
|
32
|
+
* time, so a seat that arrives after the plugin needing it never arrives. */
|
|
33
|
+
const BUILTIN_SEATS = ['llm', 'credentials', 'tools', 'web', 'systemPrompt'];
|
|
34
|
+
|
|
35
|
+
let state;
|
|
36
|
+
|
|
37
|
+
class ComposeError extends Error {
|
|
38
|
+
constructor(code, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.name = 'ComposeError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Applies one node's `isolate` declaration to its parent context.
|
|
46
|
+
///
|
|
47
|
+
/// Cordis semantics, unchanged: `true` mints an entry-local realm; a string
|
|
48
|
+
/// labels a realm SHARED by every node using the same label, so an in-group
|
|
49
|
+
/// provider serves in-group consumers while the outside world keeps its own
|
|
50
|
+
/// service under the same name.
|
|
51
|
+
function derive(parent, node, realms) {
|
|
52
|
+
let derived = parent;
|
|
53
|
+
if (node.isolate && typeof node.isolate === 'object') {
|
|
54
|
+
for (const [service, label] of Object.entries(node.isolate)) {
|
|
55
|
+
if (label === true) {
|
|
56
|
+
derived = derived.isolate(service);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const key = `${service} ${label}`;
|
|
60
|
+
if (!realms.has(key)) realms.set(key, Symbol(`${service}#${label}`));
|
|
61
|
+
derived = derived.isolate(service, realms.get(key));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return derived;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Walks the declared structure into a placement per entry id.
|
|
68
|
+
///
|
|
69
|
+
/// The value is the context an entry is mounted *under*, not the entry's own —
|
|
70
|
+
/// that one is derived per load, because it carries the load's registration
|
|
71
|
+
/// sink and each load needs its own.
|
|
72
|
+
function place(nodes, parent, placements, groups, realms) {
|
|
73
|
+
for (const node of nodes ?? []) {
|
|
74
|
+
const id = node?.id;
|
|
75
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
76
|
+
throw new ComposeError('[BAD_STRUCTURE]', 'every entry in the composition structure needs an id');
|
|
77
|
+
}
|
|
78
|
+
if (placements.has(id) || groups.has(id)) {
|
|
79
|
+
throw new ComposeError('[BAD_STRUCTURE]', `duplicate entry id ${JSON.stringify(id)}`);
|
|
80
|
+
}
|
|
81
|
+
const derived = derive(parent, node, realms);
|
|
82
|
+
if (Array.isArray(node.group)) {
|
|
83
|
+
groups.set(id, derived);
|
|
84
|
+
place(node.group, derived, placements, groups, realms);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
placements.set(id, derived);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Creates the realm. Called once, by the composition control plugin.
|
|
92
|
+
export async function createRealm({ web = null, entries = [] } = {}) {
|
|
93
|
+
if (state !== undefined) {
|
|
94
|
+
throw new ComposeError('[REALM_EXISTS]', 'the composition realm is already created');
|
|
95
|
+
}
|
|
96
|
+
const ctx = new Context();
|
|
97
|
+
const placements = new Map();
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
const realms = new Map();
|
|
100
|
+
place(entries, ctx, placements, groups, realms);
|
|
101
|
+
|
|
102
|
+
await ctx.plugin(RebonLlmRuntime);
|
|
103
|
+
await ctx.plugin(RebonCredentials);
|
|
104
|
+
await ctx.plugin(RebonToolsRuntime);
|
|
105
|
+
await ctx.plugin(RebonWebRuntime, { web });
|
|
106
|
+
await ctx.plugin(RebonSystemPromptRuntime);
|
|
107
|
+
|
|
108
|
+
state = { ctx, placements, groups, realms, fibers: new Map(), sinks: new Map() };
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// The realm, or a refusal that names the ordering mistake.
|
|
113
|
+
export function realm() {
|
|
114
|
+
if (state === undefined) {
|
|
115
|
+
throw new ComposeError(
|
|
116
|
+
'[NO_REALM]',
|
|
117
|
+
'the composition realm does not exist yet; rebon loads the composition control plugin before any entry',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Tears the realm down. Only tests need this; a real host exits instead.
|
|
124
|
+
export async function destroyRealm() {
|
|
125
|
+
if (state === undefined) return;
|
|
126
|
+
for (const pluginId of [...state.fibers.keys()].reverse()) await disposeEntry(pluginId);
|
|
127
|
+
await state.ctx.dispose?.();
|
|
128
|
+
state = undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function builtinSeats() {
|
|
132
|
+
return [...BUILTIN_SEATS];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// The services a Cordis plugin cannot start without.
|
|
136
|
+
///
|
|
137
|
+
/// Cordis accepts both spellings: a bare array is all-required, an object
|
|
138
|
+
/// separates `required` from `optional`.
|
|
139
|
+
function requiredInjects(plugin) {
|
|
140
|
+
const inject = plugin?.inject;
|
|
141
|
+
if (Array.isArray(inject)) return inject;
|
|
142
|
+
if (inject && typeof inject === 'object' && Array.isArray(inject.required)) return inject.required;
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Mounts one Cordis entry and hands back what it registered.
|
|
147
|
+
///
|
|
148
|
+
/// The sink rides on the entry's own derived context, which is what lets a seat
|
|
149
|
+
/// attribute a registration synchronously — the moment a plugin registers, the
|
|
150
|
+
/// context it registered through says which plugin it was.
|
|
151
|
+
export async function mountEntry(request, plugin) {
|
|
152
|
+
const live = realm();
|
|
153
|
+
const { pluginId } = request;
|
|
154
|
+
if (live.groups.has(pluginId)) {
|
|
155
|
+
throw new ComposeError(
|
|
156
|
+
'[GROUP_NOT_LOADABLE]',
|
|
157
|
+
`${pluginId} is a group in the composition structure, not a module; load its members`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (live.fibers.has(pluginId)) {
|
|
161
|
+
throw new ComposeError('[PLUGIN_ALREADY_LOADED]', `${pluginId} is already mounted`);
|
|
162
|
+
}
|
|
163
|
+
const parent = live.placements.get(pluginId) ?? live.ctx;
|
|
164
|
+
const sink = new RegistrationSink(pluginId, request);
|
|
165
|
+
const derived = parent.extend({ [OWNER]: pluginId, [SINK]: sink });
|
|
166
|
+
// Refused before mounting rather than waited for. Cordis lets a plugin wait
|
|
167
|
+
// for a service that has not arrived yet, and inside one composition that is
|
|
168
|
+
// a feature — but a load is a call rebon is waiting on, and a plugin waiting
|
|
169
|
+
// forever is a call that never answers. So a composition entry's required
|
|
170
|
+
// services must be there when it loads, and rebon orders entries so they
|
|
171
|
+
// are. Saying which one is missing turns a hang into a diagnosis.
|
|
172
|
+
for (const service of requiredInjects(plugin)) {
|
|
173
|
+
if (derived.get(service) === undefined) {
|
|
174
|
+
throw new ComposeError(
|
|
175
|
+
'[MISSING_INJECT]',
|
|
176
|
+
`${pluginId} requires service ${JSON.stringify(service)}, which nothing provides where it is mounted`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let fiber;
|
|
181
|
+
try {
|
|
182
|
+
fiber = derived.plugin(plugin, request.config ?? undefined);
|
|
183
|
+
await fiber;
|
|
184
|
+
} catch (cause) {
|
|
185
|
+
// Whatever half-mounted has to go: a fiber left behind would keep serving
|
|
186
|
+
// through registrations rebon was never told about.
|
|
187
|
+
try {
|
|
188
|
+
await fiber?.dispose();
|
|
189
|
+
} catch {}
|
|
190
|
+
if (cause?.code) throw cause;
|
|
191
|
+
throw new ComposeError('[ACTIVATE_FAILED]', `mounting ${pluginId} failed: ${cause?.message ?? cause}`);
|
|
192
|
+
}
|
|
193
|
+
// Every catalog read has to finish before the report is sealed: a provider
|
|
194
|
+
// reported without its models would have rebon resolving against a table
|
|
195
|
+
// that is still being filled in.
|
|
196
|
+
await live.ctx.llm.settle();
|
|
197
|
+
sink.seal();
|
|
198
|
+
live.fibers.set(pluginId, fiber);
|
|
199
|
+
live.sinks.set(pluginId, sink);
|
|
200
|
+
return sink;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Disposes one entry. Idempotent: the host retires a plugin once, but a realm
|
|
204
|
+
/// torn down underneath it must not turn that into a second failure.
|
|
205
|
+
export async function disposeEntry(pluginId) {
|
|
206
|
+
if (state === undefined) return;
|
|
207
|
+
const fiber = state.fibers.get(pluginId);
|
|
208
|
+
state.fibers.delete(pluginId);
|
|
209
|
+
state.sinks.delete(pluginId);
|
|
210
|
+
if (fiber === undefined) return;
|
|
211
|
+
await fiber.dispose();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// What one entry reported beyond the protocol's own fields.
|
|
215
|
+
export function reportFor(pluginId) {
|
|
216
|
+
const sink = realm().sinks.get(pluginId);
|
|
217
|
+
return sink === undefined ? undefined : sink.report();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Every entry currently mounted, in load order.
|
|
221
|
+
export function mounted() {
|
|
222
|
+
return [...realm().fibers.keys()];
|
|
223
|
+
}
|