@orbit-intelligence/orbit-agent 0.3.12
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 +16 -0
- package/README.md +23 -0
- package/bin/orbit +26 -0
- package/dist/prompts/system.js +80 -0
- package/dist/src/cli/args.js +145 -0
- package/dist/src/cli/orchestrate.js +100 -0
- package/dist/src/cli/run.js +393 -0
- package/dist/src/config/config-schema.js +151 -0
- package/dist/src/config/index.js +57 -0
- package/dist/src/core/agent/agent-loop.js +402 -0
- package/dist/src/core/agents/delegate.js +120 -0
- package/dist/src/core/agents/orchestrator.js +58 -0
- package/dist/src/core/agents/prompts.js +82 -0
- package/dist/src/core/agents/types.js +1 -0
- package/dist/src/core/context/context-manager.js +167 -0
- package/dist/src/core/events.js +23 -0
- package/dist/src/core/llm/http.js +207 -0
- package/dist/src/core/llm/index.js +93 -0
- package/dist/src/core/llm/models.js +228 -0
- package/dist/src/core/llm/providers/gemini.js +211 -0
- package/dist/src/core/llm/providers/openai-compat.js +31 -0
- package/dist/src/core/llm/router.js +125 -0
- package/dist/src/core/llm/secrets.js +121 -0
- package/dist/src/core/llm/types.js +10 -0
- package/dist/src/core/orchestration/dispatcher.js +74 -0
- package/dist/src/core/orchestration/messenger.js +139 -0
- package/dist/src/core/orchestration/roles.js +129 -0
- package/dist/src/core/orchestration/runtime.js +122 -0
- package/dist/src/core/orchestration/session.js +204 -0
- package/dist/src/core/orchestration/shared-context.js +88 -0
- package/dist/src/core/orchestration/tools.js +187 -0
- package/dist/src/core/orchestration/types.js +3 -0
- package/dist/src/core/permissions/index.js +58 -0
- package/dist/src/core/project-context.js +115 -0
- package/dist/src/core/skill-loader.js +31 -0
- package/dist/src/core/tools/edit.js +142 -0
- package/dist/src/core/tools/filesystem.js +203 -0
- package/dist/src/core/tools/git.js +138 -0
- package/dist/src/core/tools/registry.js +73 -0
- package/dist/src/core/tools/search.js +90 -0
- package/dist/src/core/tools/shell.js +65 -0
- package/dist/src/core/tools/types.js +6 -0
- package/dist/src/core/types.js +3 -0
- package/dist/src/index.js +11 -0
- package/dist/src/session/event-log.js +55 -0
- package/dist/src/session/store.js +76 -0
- package/dist/src/setup/wizard.js +401 -0
- package/dist/src/tui/InkApp.js +67 -0
- package/dist/src/tui/ansi.js +142 -0
- package/dist/src/tui/app.js +768 -0
- package/dist/src/tui/colors.js +13 -0
- package/dist/src/tui/components/AgentDock.js +46 -0
- package/dist/src/tui/components/Composer.js +35 -0
- package/dist/src/tui/components/Header.js +23 -0
- package/dist/src/tui/components/ModelPicker.js +23 -0
- package/dist/src/tui/components/PermissionModal.js +29 -0
- package/dist/src/tui/components/SlashMenu.js +15 -0
- package/dist/src/tui/components/StatusLine.js +27 -0
- package/dist/src/tui/components/Transcript.js +31 -0
- package/dist/src/tui/components/WorkingStatus.js +29 -0
- package/dist/src/tui/components/input.js +246 -0
- package/dist/src/tui/components/markdown.js +384 -0
- package/dist/src/tui/components/message.js +105 -0
- package/dist/src/tui/context.js +8 -0
- package/dist/src/tui/geometry.js +40 -0
- package/dist/src/tui/renderer.js +116 -0
- package/dist/src/tui/rows.js +247 -0
- package/dist/src/tui/scheduler.js +32 -0
- package/dist/src/tui/store.js +127 -0
- package/dist/src/tui/style.js +151 -0
- package/dist/src/tui/term.js +309 -0
- package/dist/src/tui/text.js +104 -0
- package/dist/src/tui/themes/index.js +15 -0
- package/dist/src/tui/themes/palettes.js +137 -0
- package/dist/src/tui/themes/types.js +1 -0
- package/dist/src/utils/diff.js +161 -0
- package/dist/src/utils/platform.js +71 -0
- package/dist/src/utils/signals.js +26 -0
- package/dist/src/version.js +4 -0
- package/package.json +71 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { combineSignals } from '../../utils/signals.js';
|
|
2
|
+
import { unifiedDiff, countChanges } from '../../utils/diff.js';
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { resolve, relative } from 'node:path';
|
|
5
|
+
import { isBinaryish } from '../tools/filesystem.js';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
const MUTATING_TOOLS = {
|
|
8
|
+
git_add: 'git add',
|
|
9
|
+
git_commit: 'git commit',
|
|
10
|
+
};
|
|
11
|
+
export class AgentLoop {
|
|
12
|
+
opts;
|
|
13
|
+
runAbort = null;
|
|
14
|
+
turnCount = 0;
|
|
15
|
+
delegationsUsed = 0;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.opts = opts;
|
|
18
|
+
}
|
|
19
|
+
/** Interrupt the in-flight run (stream or a parked tool). Safe to call anytime. */
|
|
20
|
+
abort() {
|
|
21
|
+
this.runAbort?.abort();
|
|
22
|
+
}
|
|
23
|
+
/** The active run's abort signal (undefined between runs). */
|
|
24
|
+
get runSignal() {
|
|
25
|
+
return this.runAbort?.signal;
|
|
26
|
+
}
|
|
27
|
+
get delegationsUsedCount() {
|
|
28
|
+
return this.delegationsUsed;
|
|
29
|
+
}
|
|
30
|
+
async run(userText) {
|
|
31
|
+
this.turnCount = 0;
|
|
32
|
+
this.runAbort = new AbortController();
|
|
33
|
+
const bus = this.opts.bus;
|
|
34
|
+
const userMsg = this.opts.context.addUser(userText);
|
|
35
|
+
bus.emit('onUserMessage', userMsg);
|
|
36
|
+
return this.iterate();
|
|
37
|
+
}
|
|
38
|
+
async iterate() {
|
|
39
|
+
const bus = this.opts.bus;
|
|
40
|
+
const max = this.opts.maxIterations ?? 16;
|
|
41
|
+
const runSignal = () => this.runAbort?.signal;
|
|
42
|
+
for (let turn = 0; turn < max; turn++) {
|
|
43
|
+
this.turnCount = turn + 1;
|
|
44
|
+
// Compaction: keep the outgoing context inside budget.
|
|
45
|
+
const budget = this.opts.contextBudgetTokens ?? 0;
|
|
46
|
+
if (budget > 0) {
|
|
47
|
+
const compacted = this.opts.context.compact(budget);
|
|
48
|
+
if (compacted.droppedPairs > 0) {
|
|
49
|
+
bus.emit('onContextSummary', compacted);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const signal = runSignal();
|
|
53
|
+
if (signal?.aborted)
|
|
54
|
+
return { turns: this.turnCount, interrupted: true };
|
|
55
|
+
const route = this.opts.router.peek();
|
|
56
|
+
if (route)
|
|
57
|
+
bus.emit('onRoute', { ...route, strategy: route.strategy });
|
|
58
|
+
const asstMsg = {
|
|
59
|
+
id: `a_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
60
|
+
role: 'assistant',
|
|
61
|
+
content: '',
|
|
62
|
+
streaming: true,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
model: route?.model,
|
|
65
|
+
};
|
|
66
|
+
bus.emit('onAssistantStart', asstMsg);
|
|
67
|
+
const toolCalls = [];
|
|
68
|
+
let usage;
|
|
69
|
+
// Up-front failures (429, flaky connection, route hot-swap) are retried
|
|
70
|
+
// once after a short backoff: nothing was streamed, so replaying the
|
|
71
|
+
// identical context is safe. Failures after output started are terminal.
|
|
72
|
+
for (let attempt = 0;; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
const streamCtl = new AbortController();
|
|
75
|
+
const streamTimeoutMs = this.opts.streamTimeoutMs ?? 120_000;
|
|
76
|
+
const timer = setTimeout(() => streamCtl.abort(new Error(`stream timed out after ${streamTimeoutMs}ms`)), streamTimeoutMs);
|
|
77
|
+
const streamSignal = combineSignals([signal, streamCtl.signal]);
|
|
78
|
+
const gen = this.opts.router.stream({
|
|
79
|
+
messages: this.opts.context.toOutgoing(),
|
|
80
|
+
tools: this.opts.tools.toToolDefs(),
|
|
81
|
+
model: '',
|
|
82
|
+
signal: streamSignal,
|
|
83
|
+
retries: 1,
|
|
84
|
+
retryDelayMs: 600,
|
|
85
|
+
reasoning: this.opts.reasoning,
|
|
86
|
+
});
|
|
87
|
+
try {
|
|
88
|
+
for await (const ev of gen) {
|
|
89
|
+
if (ev.type === 'token') {
|
|
90
|
+
asstMsg.content += ev.text;
|
|
91
|
+
bus.emit('onToken', ev.text);
|
|
92
|
+
await throttle(this.opts.maxTokensPerSecond);
|
|
93
|
+
}
|
|
94
|
+
else if (ev.type === 'reasoning') {
|
|
95
|
+
asstMsg.reasoning = (asstMsg.reasoning ?? '') + ev.text;
|
|
96
|
+
bus.emit('onThinking', ev.text);
|
|
97
|
+
}
|
|
98
|
+
else if (ev.type === 'tool_call_start') {
|
|
99
|
+
const call = {
|
|
100
|
+
id: ev.id,
|
|
101
|
+
name: ev.name,
|
|
102
|
+
args: '',
|
|
103
|
+
status: 'pending',
|
|
104
|
+
startedAt: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
toolCalls.push(call);
|
|
107
|
+
bus.emit('onToolStart', call);
|
|
108
|
+
}
|
|
109
|
+
else if (ev.type === 'tool_call_args') {
|
|
110
|
+
const call = toolCalls.find((c) => c.id === ev.id);
|
|
111
|
+
if (call) {
|
|
112
|
+
call.args += ev.args;
|
|
113
|
+
bus.emit('onToolUpdate', { ...call });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (ev.type === 'tool_call_end') {
|
|
117
|
+
const call = toolCalls.find((c) => c.id === ev.id);
|
|
118
|
+
if (call) {
|
|
119
|
+
call.name = ev.name;
|
|
120
|
+
call.args = ev.args;
|
|
121
|
+
call.status = 'running';
|
|
122
|
+
bus.emit('onToolUpdate', { ...call });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (ev.type === 'done') {
|
|
126
|
+
usage = ev.usage;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (signal?.aborted) {
|
|
138
|
+
bus.emit('onError', new Error('Interrupted.'));
|
|
139
|
+
bus.emit('onAssistantEnd', asstMsg);
|
|
140
|
+
return { turns: this.turnCount, interrupted: true };
|
|
141
|
+
}
|
|
142
|
+
const nothingEmitted = asstMsg.content.length === 0 && toolCalls.length === 0;
|
|
143
|
+
if (nothingEmitted && attempt === 0) {
|
|
144
|
+
await sleep(1200);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
bus.emit('onError', err);
|
|
148
|
+
bus.emit('onAssistantEnd', asstMsg);
|
|
149
|
+
return { turns: this.turnCount, error: err?.message ?? String(err) };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
asstMsg.streaming = false;
|
|
153
|
+
asstMsg.reasoningOpen = (asstMsg.reasoning?.length ?? 0) > 0;
|
|
154
|
+
asstMsg.toolCalls = toolCalls;
|
|
155
|
+
asstMsg.usage = usage;
|
|
156
|
+
asstMsg.model = route?.model ?? asstMsg.model;
|
|
157
|
+
if (toolCalls.length === 0) {
|
|
158
|
+
bus.emit('onAssistantEnd', asstMsg);
|
|
159
|
+
if (asstMsg.content.trim().length > 0 || this.turnCount >= max) {
|
|
160
|
+
this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
|
|
161
|
+
return { turns: this.turnCount };
|
|
162
|
+
}
|
|
163
|
+
// Whitespace-only generation (Gemini occasionally emits a bare "\n");
|
|
164
|
+
// don't pollute history, burn a turn slot, and try the model again.
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Execute each tool call, feed results back into context
|
|
168
|
+
for (const call of toolCalls) {
|
|
169
|
+
await this.executeTool(call);
|
|
170
|
+
}
|
|
171
|
+
this.opts.context.addAssistant(asstMsg.content, { model: asstMsg.model, toolCalls });
|
|
172
|
+
bus.emit('onAssistantEnd', asstMsg);
|
|
173
|
+
// Overflow-driven compaction (adapted from opencode's loop protection):
|
|
174
|
+
// after a tool-heavy turn, bring the outgoing context back inside budget
|
|
175
|
+
// before the next model call so a long session keeps functioning.
|
|
176
|
+
if (budget > 0 && toolCalls.length > 0) {
|
|
177
|
+
const compacted = this.opts.context.compact(budget);
|
|
178
|
+
if (compacted.droppedPairs > 0) {
|
|
179
|
+
bus.emit('onContextSummary', compacted);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
bus.emit('onError', new Error(`Max iterations (${max}) reached`));
|
|
184
|
+
return { turns: this.turnCount };
|
|
185
|
+
}
|
|
186
|
+
async executeTool(call) {
|
|
187
|
+
const bus = this.opts.bus;
|
|
188
|
+
const startedAt = Date.now();
|
|
189
|
+
// Delegation: the lead hands work to a sub-agent (synchronous, bounded).
|
|
190
|
+
if (call.name === 'delegate_task' && this.opts.delegate) {
|
|
191
|
+
await this.runDelegation(call);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
let denied = false;
|
|
195
|
+
if (call.name === 'run_shell') {
|
|
196
|
+
const decision = await this.opts.permissions.checkCommand(parseCommandArg(call.args));
|
|
197
|
+
denied = decision === 'deny';
|
|
198
|
+
}
|
|
199
|
+
else if (call.name === 'write_file') {
|
|
200
|
+
const decision = await this.opts.permissions.checkPath(parseWritePath(call.args), 'write');
|
|
201
|
+
denied = decision === 'deny';
|
|
202
|
+
}
|
|
203
|
+
else if (MUTATING_TOOLS[call.name]) {
|
|
204
|
+
const decision = await this.opts.permissions.checkCommand(MUTATING_TOOLS[call.name]);
|
|
205
|
+
denied = decision === 'deny';
|
|
206
|
+
}
|
|
207
|
+
if (this.opts.readOnly && call.name !== 'run_shell') {
|
|
208
|
+
const readOnlyDenied = ['edit_file', 'write_file', 'git_add', 'git_commit'].includes(call.name);
|
|
209
|
+
denied = denied || readOnlyDenied;
|
|
210
|
+
}
|
|
211
|
+
bus.emit('onToolUpdate', { ...call, status: 'running' });
|
|
212
|
+
if (denied) {
|
|
213
|
+
call.status = 'error';
|
|
214
|
+
call.result = 'Blocked by permission policy.';
|
|
215
|
+
call.endedAt = Date.now();
|
|
216
|
+
call.durationMs = call.endedAt - startedAt;
|
|
217
|
+
bus.emit('onToolEnd', call);
|
|
218
|
+
this.opts.context.addToolResult(call, call.result, true);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const tool = this.opts.tools.get(call.name);
|
|
222
|
+
let result;
|
|
223
|
+
if (!tool) {
|
|
224
|
+
result = { content: `Unknown tool: ${call.name}`, isError: true };
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
let args = {};
|
|
228
|
+
try {
|
|
229
|
+
args = JSON.parse(call.args);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
args = { value: call.args };
|
|
233
|
+
}
|
|
234
|
+
// Diff review: snapshot the target file before a mutating edit so the
|
|
235
|
+
// change can be surfaced to the user as a reviewable unified diff.
|
|
236
|
+
const diffSnapshot = await captureSnapshot(args, this.opts.cwd ?? '', call.name);
|
|
237
|
+
// Per-tool timeout + run abort, combined.
|
|
238
|
+
const toolCtl = new AbortController();
|
|
239
|
+
const timeoutMs = this.opts.timeoutMs ?? 30_000;
|
|
240
|
+
const timer = setTimeout(() => toolCtl.abort(new Error(`tool timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
241
|
+
const toolSignal = combineSignals([this.runAbort?.signal, toolCtl.signal]);
|
|
242
|
+
const ctx = this.opts.tools.createToolContext();
|
|
243
|
+
ctx.canWrite = !this.opts.readOnly;
|
|
244
|
+
ctx.signal = toolSignal;
|
|
245
|
+
if (this.opts.toolContextExtras)
|
|
246
|
+
Object.assign(ctx, this.opts.toolContextExtras);
|
|
247
|
+
try {
|
|
248
|
+
result = await raceTimeout(tool.run(args, ctx), timeoutMs + 1_000, `tool timed out after ${timeoutMs}ms`);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
result = { content: `Tool error: ${err.message}`, isError: true };
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
if (!result.isError && diffSnapshot) {
|
|
257
|
+
await this.emitDiff(diffSnapshot, result.content);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
call.status = result.isError ? 'error' : 'done';
|
|
261
|
+
call.result = result.content;
|
|
262
|
+
call.endedAt = Date.now();
|
|
263
|
+
call.durationMs = call.endedAt - startedAt;
|
|
264
|
+
bus.emit('onToolEnd', call);
|
|
265
|
+
this.opts.context.addToolResult(call, result.content, result.isError ?? false);
|
|
266
|
+
}
|
|
267
|
+
async emitDiff(snapshot, toolResult) {
|
|
268
|
+
try {
|
|
269
|
+
const after = await readFile(snapshot.resolvedPath, 'utf8').catch(() => null);
|
|
270
|
+
if (after === null || after === snapshot.before)
|
|
271
|
+
return;
|
|
272
|
+
const label = snapshot.relPath;
|
|
273
|
+
const diff = unifiedDiff(snapshot.before ?? '', after, label);
|
|
274
|
+
if (!diff)
|
|
275
|
+
return;
|
|
276
|
+
const { added, removed } = countChanges(diff);
|
|
277
|
+
const fileDiff = { path: label, diff, added, removed };
|
|
278
|
+
this.opts.bus.emit('onFileDiff', fileDiff);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Diff capture is best-effort; never break the loop over it.
|
|
282
|
+
}
|
|
283
|
+
void toolResult;
|
|
284
|
+
}
|
|
285
|
+
async runDelegation(call) {
|
|
286
|
+
const bus = this.opts.bus;
|
|
287
|
+
const startedAt = Date.now();
|
|
288
|
+
const maxDelegations = this.opts.maxDelegations ?? 6;
|
|
289
|
+
bus.emit('onToolUpdate', { ...call, status: 'running' });
|
|
290
|
+
if (this.delegationsUsed >= maxDelegations) {
|
|
291
|
+
call.status = 'error';
|
|
292
|
+
call.result = `Blocked: delegation budget (${maxDelegations}) exhausted this turn.`;
|
|
293
|
+
call.endedAt = Date.now();
|
|
294
|
+
call.durationMs = call.endedAt - startedAt;
|
|
295
|
+
bus.emit('onToolEnd', call);
|
|
296
|
+
this.opts.context.addToolResult(call, call.result, true);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.delegationsUsed++;
|
|
300
|
+
let args = {};
|
|
301
|
+
try {
|
|
302
|
+
args = JSON.parse(call.args);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
args = {};
|
|
306
|
+
}
|
|
307
|
+
const parallel = Array.isArray(args.parallel) ? args.parallel.filter((j) => j && j.task) : [];
|
|
308
|
+
const jobs = parallel.length > 0
|
|
309
|
+
? parallel.map((j) => ({ role: String(j.role ?? 'coder'), task: String(j.task ?? ''), files: j.files }))
|
|
310
|
+
: [{ role: String(args.role ?? 'coder'), task: String(args.task ?? ''), files: args.files }];
|
|
311
|
+
if (jobs.some((j) => !j.task.trim())) {
|
|
312
|
+
call.status = 'error';
|
|
313
|
+
call.result = 'Error: delegate_task requires a non-empty task for every job (single or parallel).';
|
|
314
|
+
call.endedAt = Date.now();
|
|
315
|
+
call.durationMs = call.endedAt - startedAt;
|
|
316
|
+
bus.emit('onToolEnd', call);
|
|
317
|
+
this.opts.context.addToolResult(call, call.result, true);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const impl = this.opts.delegate;
|
|
321
|
+
const results = await Promise.all(jobs.map((j) => impl(j)));
|
|
322
|
+
const ok = results.every((r) => r.ok);
|
|
323
|
+
const lines = results
|
|
324
|
+
.map((r, i) => `<delegated-${jobs[i].role} ok="${r.ok}">\n${r.summary}\n</delegated-${jobs[i].role}>`)
|
|
325
|
+
.join('\n\n');
|
|
326
|
+
call.status = ok ? 'done' : 'error';
|
|
327
|
+
call.result = lines;
|
|
328
|
+
call.endedAt = Date.now();
|
|
329
|
+
call.durationMs = call.endedAt - startedAt;
|
|
330
|
+
bus.emit('onToolEnd', call);
|
|
331
|
+
this.opts.context.addToolResult(call, lines, !ok);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function parseCommandArg(args) {
|
|
335
|
+
try {
|
|
336
|
+
const parsed = JSON.parse(args);
|
|
337
|
+
return parsed.command ?? '';
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return args;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function parseWritePath(args) {
|
|
344
|
+
try {
|
|
345
|
+
const parsed = JSON.parse(args);
|
|
346
|
+
return parsed.path ?? '';
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function resolveCwdPath(p, cwd) {
|
|
353
|
+
if (p === '~')
|
|
354
|
+
return homedir();
|
|
355
|
+
if (p.startsWith('~/'))
|
|
356
|
+
return `${homedir()}${p.slice(1)}`;
|
|
357
|
+
if (p.startsWith('/'))
|
|
358
|
+
return p;
|
|
359
|
+
return resolve(cwd, p);
|
|
360
|
+
}
|
|
361
|
+
async function captureSnapshot(args, cwd, toolName) {
|
|
362
|
+
if (toolName !== 'edit_file' && toolName !== 'write_file')
|
|
363
|
+
return null;
|
|
364
|
+
const rawPath = typeof args['path'] === 'string' ? args['path'] : null;
|
|
365
|
+
if (!rawPath)
|
|
366
|
+
return null;
|
|
367
|
+
const resolvedPath = resolveCwdPath(rawPath, cwd);
|
|
368
|
+
try {
|
|
369
|
+
const st = await stat(resolvedPath);
|
|
370
|
+
if (st.size > 1_500_000 || isBinaryish(resolvedPath))
|
|
371
|
+
return null;
|
|
372
|
+
const before = await readFile(resolvedPath, 'utf8');
|
|
373
|
+
return { resolvedPath, relPath: relative(cwd, resolvedPath) || rawPath, before };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// File doesn't exist yet → treat as empty "before" for a create diff.
|
|
377
|
+
return { resolvedPath, relPath: relative(cwd, resolvedPath) || rawPath, before: null };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function throttle(maxTokensPerSecond) {
|
|
381
|
+
if (!maxTokensPerSecond || maxTokensPerSecond <= 0)
|
|
382
|
+
return;
|
|
383
|
+
await new Promise((r) => setTimeout(r, 1000 / maxTokensPerSecond));
|
|
384
|
+
}
|
|
385
|
+
async function raceTimeout(p, ms, message) {
|
|
386
|
+
let timer;
|
|
387
|
+
try {
|
|
388
|
+
return await Promise.race([
|
|
389
|
+
p,
|
|
390
|
+
new Promise((_, reject) => {
|
|
391
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
392
|
+
}),
|
|
393
|
+
]);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
if (timer)
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function sleep(ms) {
|
|
401
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
402
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { AgentLoop } from '../agent/agent-loop.js';
|
|
2
|
+
import { ContextManager } from '../context/context-manager.js';
|
|
3
|
+
import { READ_ONLY_TOOLS, CODER_TOOLS } from '../tools/registry.js';
|
|
4
|
+
import { buildRoleSystemPrompt } from './prompts.js';
|
|
5
|
+
const REVIEWER_TOOLS = [...READ_ONLY_TOOLS, 'run_shell'];
|
|
6
|
+
const BY_ROLE = {
|
|
7
|
+
architect: READ_ONLY_TOOLS,
|
|
8
|
+
coder: CODER_TOOLS,
|
|
9
|
+
reviewer: REVIEWER_TOOLS,
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Builds the delegate implementation bound to the live agent runtime.
|
|
13
|
+
* Each delegation runs a fresh sub-agent: isolated ContextManager, a
|
|
14
|
+
* role-restricted tool registry (shared implementations), same router,
|
|
15
|
+
* same permission policy. The parent context is NOT copied into the
|
|
16
|
+
* sub-agent — the task description is the only interface, which keeps
|
|
17
|
+
* context cheap.
|
|
18
|
+
*/
|
|
19
|
+
export function createDelegate(ctx) {
|
|
20
|
+
const cache = new Map();
|
|
21
|
+
return async (req) => {
|
|
22
|
+
if (!(req.role in BY_ROLE)) {
|
|
23
|
+
return { ok: false, summary: `unknown role: ${req.role}` };
|
|
24
|
+
}
|
|
25
|
+
const allowed = BY_ROLE[req.role];
|
|
26
|
+
const isReadOnly = req.role !== 'coder';
|
|
27
|
+
ctx.bus.emit('onAgentStart', { role: req.role, task: req.task });
|
|
28
|
+
let ok = false;
|
|
29
|
+
let summary = '(sub-agent produced no summary)';
|
|
30
|
+
try {
|
|
31
|
+
const systemPrompt = buildRoleSystemPrompt(req.role, {
|
|
32
|
+
cwd: ctx.cwd,
|
|
33
|
+
task: req.task,
|
|
34
|
+
files: req.files,
|
|
35
|
+
});
|
|
36
|
+
const subContext = new ContextManager(systemPrompt);
|
|
37
|
+
const subRegistry = ctx.registry.restrict(allowed);
|
|
38
|
+
const sub = new AgentLoop({
|
|
39
|
+
bus: ctx.bus,
|
|
40
|
+
router: ctx.router,
|
|
41
|
+
context: subContext,
|
|
42
|
+
tools: subRegistry,
|
|
43
|
+
permissions: ctx.permissions,
|
|
44
|
+
systemPrompt,
|
|
45
|
+
maxIterations: ctx.config.agent?.maxIterations ?? 12,
|
|
46
|
+
cwd: ctx.cwd,
|
|
47
|
+
timeoutMs: ctx.config.tools?.timeoutMs ?? 30_000,
|
|
48
|
+
streamTimeoutMs: ctx.config.runtime?.streamTimeoutMs ?? 120_000,
|
|
49
|
+
contextBudgetTokens: ctx.config.agent?.contextBudgetTokens ?? 48_000,
|
|
50
|
+
readOnly: isReadOnly,
|
|
51
|
+
reasoning: ctx.reasoning,
|
|
52
|
+
});
|
|
53
|
+
const res = await sub.run(req.task);
|
|
54
|
+
const last = lastAssistantMessage(subContext.history());
|
|
55
|
+
summary = last && last.content.trim() ? last.content.trim() : '(sub-agent finished without a summary)';
|
|
56
|
+
ok = !res.interrupted && Boolean(last?.content.trim());
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
summary = `sub-agent crashed: ${err.message}`;
|
|
60
|
+
ok = false;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
void cache;
|
|
64
|
+
ctx.bus.emit('onAgentEnd', { role: req.role, ok, summary });
|
|
65
|
+
}
|
|
66
|
+
return { ok, summary };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function lastAssistantMessage(messages) {
|
|
70
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
71
|
+
const m = messages[i];
|
|
72
|
+
if (m.role === 'assistant' && m.content.trim())
|
|
73
|
+
return m;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/** Tool exposed only to the lead loop: delegates work to a sub-agent. */
|
|
78
|
+
export function createDelegateTool(opts) {
|
|
79
|
+
return {
|
|
80
|
+
name: 'delegate_task',
|
|
81
|
+
description: 'Delegate a self-contained subtask to a specialist sub-agent (architect, coder, or reviewer). The sub-agent runs its own focused loop with an isolated context seeded with your description, then returns a structured summary. Use for: architecture plans (architect, read-only), implementation (coder), independent verification (reviewer, has test shell access). Do not delegate trivial steps — only work that benefits from a dedicated focus.',
|
|
82
|
+
parameters: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
role: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
enum: ['architect', 'coder', 'reviewer'],
|
|
88
|
+
description: 'architect = read-only plan/design; coder = implements with full tools; reviewer = read-only review + test commands.',
|
|
89
|
+
},
|
|
90
|
+
task: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'Self-contained, specific description of the subtask. Include file paths, constraints, and the definition of done.',
|
|
93
|
+
},
|
|
94
|
+
files: {
|
|
95
|
+
type: 'array',
|
|
96
|
+
items: { type: 'string' },
|
|
97
|
+
description: 'Optional list of relevant files to bound the sub-agent scope.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ['role', 'task'],
|
|
101
|
+
},
|
|
102
|
+
async run(args) {
|
|
103
|
+
const impl = opts.delegate();
|
|
104
|
+
const role = String(args.role ?? '');
|
|
105
|
+
const task = String(args.task ?? '');
|
|
106
|
+
const files = Array.isArray(args.files) ? args.files.map(String) : undefined;
|
|
107
|
+
if (!task.trim())
|
|
108
|
+
return { content: 'Error: delegate_task requires a non-empty task.', isError: true };
|
|
109
|
+
const result = await impl({ role, task, files });
|
|
110
|
+
return {
|
|
111
|
+
content: `<delegated-${role} ok="${result.ok ? 'true' : 'false'}">\n${result.summary}\n</delegated-${role}>`,
|
|
112
|
+
isError: !result.ok,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Human-readable summary of a delegation result (used by the lead). */
|
|
118
|
+
export function summarizeDelegation(r) {
|
|
119
|
+
return `\n\n[delegation ${r.ok ? 'succeeded' : 'failed'}]\n${r.summary}\n`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AgentLoop } from '../agent/agent-loop.js';
|
|
2
|
+
import { createDelegate, createDelegateTool } from './delegate.js';
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrator — the Lead agent. Runs one AgentLoop that has the full tool
|
|
5
|
+
* set PLUS the `delegate_task` tool. When the lead decides to delegate, a
|
|
6
|
+
* fresh sub-agent (architect/coder/reviewer) is spawned with an isolated
|
|
7
|
+
* context and a role-restricted registry; its summary is returned as the
|
|
8
|
+
* tool result so the lead decides next steps.
|
|
9
|
+
*/
|
|
10
|
+
export class Orchestrator {
|
|
11
|
+
lead;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
const dctx = {
|
|
14
|
+
bus: opts.bus,
|
|
15
|
+
router: opts.router,
|
|
16
|
+
permissions: opts.permissions,
|
|
17
|
+
cwd: opts.cwd,
|
|
18
|
+
registry: opts.registry,
|
|
19
|
+
config: opts.config,
|
|
20
|
+
reasoning: opts.config.reasoning,
|
|
21
|
+
};
|
|
22
|
+
const oldDelegate = createDelegate(dctx);
|
|
23
|
+
// Bridge the legacy role union to the orchestration DelegateFn signature.
|
|
24
|
+
const delegate = ((req) => {
|
|
25
|
+
if (req.role !== 'architect' && req.role !== 'coder' && req.role !== 'reviewer') {
|
|
26
|
+
return Promise.resolve({ ok: false, summary: `unknown role: ${req.role}` });
|
|
27
|
+
}
|
|
28
|
+
return oldDelegate(req);
|
|
29
|
+
});
|
|
30
|
+
opts.registry.register(createDelegateTool({ delegate: () => delegate }));
|
|
31
|
+
this.lead = new AgentLoop({
|
|
32
|
+
bus: opts.bus,
|
|
33
|
+
router: opts.router,
|
|
34
|
+
context: opts.context,
|
|
35
|
+
tools: opts.registry,
|
|
36
|
+
permissions: opts.permissions,
|
|
37
|
+
systemPrompt: opts.systemPrompt,
|
|
38
|
+
maxIterations: opts.config.agent?.maxIterations ?? 16,
|
|
39
|
+
maxDelegations: opts.config.agent?.maxDelegations ?? 6,
|
|
40
|
+
contextBudgetTokens: opts.config.agent?.contextBudgetTokens ?? 48_000,
|
|
41
|
+
timeoutMs: opts.config.tools?.timeoutMs ?? 30_000,
|
|
42
|
+
streamTimeoutMs: opts.config.runtime?.streamTimeoutMs ?? 120_000,
|
|
43
|
+
cwd: opts.cwd,
|
|
44
|
+
delegate,
|
|
45
|
+
maxTokensPerSecond: opts.maxTokensPerSecond,
|
|
46
|
+
reasoning: opts.config.reasoning,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async run(userText) {
|
|
50
|
+
return this.lead.run(userText);
|
|
51
|
+
}
|
|
52
|
+
abort() {
|
|
53
|
+
this.lead.abort();
|
|
54
|
+
}
|
|
55
|
+
get delegationsUsedCount() {
|
|
56
|
+
return this.lead.delegationsUsedCount;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { isTermux, platformLabel, defaultShell } from '../../utils/platform.js';
|
|
2
|
+
/**
|
|
3
|
+
* Sub-agent system prompts. Each agent is a self-contained AgentLoop with its
|
|
4
|
+
* own ContextManager seeded with the delegated task. The prompts are SHORT:
|
|
5
|
+
* the environment is constant, so it is injected once instead of per-message.
|
|
6
|
+
*/
|
|
7
|
+
function envBlock(cwd) {
|
|
8
|
+
const env = isTermux() ? 'Termux (Android aarch64)' : platformLabel();
|
|
9
|
+
return [
|
|
10
|
+
`- Platform: ${env}`,
|
|
11
|
+
`- Shell: ${defaultShell()}`,
|
|
12
|
+
`- Working directory: ${cwd}`,
|
|
13
|
+
].join('\n');
|
|
14
|
+
}
|
|
15
|
+
export function buildRoleSystemPrompt(role, opts) {
|
|
16
|
+
const scope = opts.files && opts.files.length > 0
|
|
17
|
+
? `\nRelevant files (focus here, do not wander):\n${opts.files.map((f) => ` - ${f}`).join('\n')}`
|
|
18
|
+
: '';
|
|
19
|
+
const base = `You are orbit-agent's ${roleTitle(role)} running as a terminal agent.
|
|
20
|
+
|
|
21
|
+
# Operating environment
|
|
22
|
+
${envBlock(opts.cwd)}
|
|
23
|
+
${scope}
|
|
24
|
+
|
|
25
|
+
# Assignment
|
|
26
|
+
${opts.task}`;
|
|
27
|
+
switch (role) {
|
|
28
|
+
case 'architect':
|
|
29
|
+
return `${base}
|
|
30
|
+
|
|
31
|
+
# Your job
|
|
32
|
+
You design the implementation plan. Explore the code, identify how things fit together, and produce a precise, minimal plan: exact files, exact changes, and the order and verification steps.
|
|
33
|
+
|
|
34
|
+
# Rules
|
|
35
|
+
- You are READ-ONLY. You cannot run shell commands or modify anything; only read, search, and inspect (including git status/diff/log).
|
|
36
|
+
- Base plans on evidence you actually read, never guesses.
|
|
37
|
+
- Prefer the smallest changes that satisfy the assignment.
|
|
38
|
+
- End with a section "## Plan" listing concrete steps with file paths.
|
|
39
|
+
- Keep the final answer under ~60 lines.`;
|
|
40
|
+
case 'coder':
|
|
41
|
+
return `${base}
|
|
42
|
+
|
|
43
|
+
# Your job
|
|
44
|
+
You implement the assignment. You have full tool access: read, edit, write, shell, git.
|
|
45
|
+
|
|
46
|
+
# Rules
|
|
47
|
+
- Read before you edit. Never write content you have not verified against the current file.
|
|
48
|
+
- Prefer edit_file over write_file for surgical changes.
|
|
49
|
+
- Run the project's checks (tests, typecheck, build) yourself before claiming success: \`npm test\`, \`node --test dist/tests/*.test.js\`, \`npm run typecheck\`, \`npm run build\` as applicable.
|
|
50
|
+
- Do not modify unrelated files. Do not commit unless the assignment says so.
|
|
51
|
+
- If something is genuinely blocked, say exactly what and why.
|
|
52
|
+
- End with "## Summary" listing what you changed and the verification you ran.`;
|
|
53
|
+
case 'reviewer':
|
|
54
|
+
return `${base}
|
|
55
|
+
|
|
56
|
+
# Your job
|
|
57
|
+
Independently review the changes against the assignment. Find real problems: bugs, regressions, architectural issues, missing tests, style drift. Do NOT rubber-stamp.
|
|
58
|
+
|
|
59
|
+
# Rules
|
|
60
|
+
- You are READ-ONLY for file contents (no edit_file/write_file) but MAY run shell commands to verify (tests, typecheck, diff, log).
|
|
61
|
+
- Verify claims: if code was changed, run the relevant checks and zip ZERO-opinion output.
|
|
62
|
+
- Report concrete, actionable findings: file:line where possible, and the exact fix.
|
|
63
|
+
- End with "## Verdict": PASS or FAIL, and if FAIL, a short list of required fixes.`;
|
|
64
|
+
default:
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function roleTitle(role) {
|
|
69
|
+
switch (role) {
|
|
70
|
+
case 'lead': return 'Lead';
|
|
71
|
+
case 'architect': return 'Architect';
|
|
72
|
+
case 'coder': return 'Coder';
|
|
73
|
+
case 'reviewer': return 'Reviewer';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export const DELEGABLE_ROLES = ['architect', 'coder', 'reviewer'];
|
|
77
|
+
export const ROLE_LABELS = {
|
|
78
|
+
lead: 'lead',
|
|
79
|
+
architect: 'architect',
|
|
80
|
+
coder: 'coder',
|
|
81
|
+
reviewer: 'reviewer',
|
|
82
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|