@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider credential handling.
|
|
3
|
+
*
|
|
4
|
+
* SECURITY: keys are read ONLY from environment variables or the optional,
|
|
5
|
+
* git-ignored, 0600 `keys.json`. They are never written to config.json,
|
|
6
|
+
* never logged, and never included in generated artifacts.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync, chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { keysPath, configDir } from '../../utils/platform.js';
|
|
10
|
+
const ENV_LABELS = {
|
|
11
|
+
orbitx: ['ORBITX_TOKEN', 'ORBITX_API_KEY'],
|
|
12
|
+
groq: ['GROQ_API_KEY'],
|
|
13
|
+
gemini: ['GEMINI_API_KEY'],
|
|
14
|
+
openrouter: ['OPENROUTER_API_KEY'],
|
|
15
|
+
};
|
|
16
|
+
// The setup wizard calls providers three times in quick succession; don't
|
|
17
|
+
// re-read + re-parse + re-chmod the keys file on every lookup.
|
|
18
|
+
const KEYS_CACHE_MS = 2000;
|
|
19
|
+
let keysCache = null;
|
|
20
|
+
function readKeysFile() {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
if (keysCache && now - keysCache.at < KEYS_CACHE_MS)
|
|
23
|
+
return keysCache.data;
|
|
24
|
+
const path = keysPath();
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
keysCache = { data: {}, at: now };
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
// best-effort tighten perms so other apps can't read it
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(path, 0o600);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
keysCache = { data: raw, at: now };
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
keysCache = { data: {}, at: now };
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function invalidateKeysCache() {
|
|
47
|
+
keysCache = null;
|
|
48
|
+
}
|
|
49
|
+
function fromEnv(provider) {
|
|
50
|
+
const labels = ENV_LABELS[provider];
|
|
51
|
+
if (!labels)
|
|
52
|
+
return [];
|
|
53
|
+
const keys = [];
|
|
54
|
+
for (const label of labels) {
|
|
55
|
+
const direct = process.env[label];
|
|
56
|
+
if (direct && !keys.includes(direct))
|
|
57
|
+
keys.push(direct);
|
|
58
|
+
// allow GROQ_API_KEY_1.._9 rotation
|
|
59
|
+
for (let i = 1; i <= 9; i++) {
|
|
60
|
+
const rotated = process.env[`${label}_${i}`];
|
|
61
|
+
if (rotated && !keys.includes(rotated))
|
|
62
|
+
keys.push(rotated);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return keys;
|
|
66
|
+
}
|
|
67
|
+
export function isImportableDirectly(p) {
|
|
68
|
+
return p === 'groq' || p === 'gemini' || p === 'openrouter' || p === 'openai';
|
|
69
|
+
}
|
|
70
|
+
export function getProviderSecrets(provider) {
|
|
71
|
+
const envKeys = fromEnv(provider);
|
|
72
|
+
if (envKeys.length > 0)
|
|
73
|
+
return { provider, keys: envKeys, source: 'env' };
|
|
74
|
+
const file = readKeysFile();
|
|
75
|
+
const fileKeys = file[provider] ?? [];
|
|
76
|
+
if (fileKeys.length > 0)
|
|
77
|
+
return { provider, keys: fileKeys, source: 'file' };
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
export function hasAnySecrets() {
|
|
81
|
+
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
|
|
82
|
+
if (getProviderSecrets(p))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
export function maskSecret(key) {
|
|
88
|
+
if (key.length <= 8)
|
|
89
|
+
return '•'.repeat(Math.min(key.length, 8));
|
|
90
|
+
return `${key.slice(0, 4)}••••••••${key.slice(-4)}`;
|
|
91
|
+
}
|
|
92
|
+
export function describeProvidersAvailable() {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const p of ['orbitx', 'groq', 'gemini', 'openrouter']) {
|
|
95
|
+
const s = getProviderSecrets(p);
|
|
96
|
+
if (s && s.keys[0])
|
|
97
|
+
out.push({ provider: p, masked: maskSecret(s.keys[0]), source: s.source });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Persist provider keys to the git-ignored keys.json (0600), merging over any
|
|
103
|
+
* existing keys so a re-run of the wizard never wipes other providers.
|
|
104
|
+
*/
|
|
105
|
+
export function writeKeys(provider, keys) {
|
|
106
|
+
const trimmed = keys.map((k) => k.trim()).filter(Boolean);
|
|
107
|
+
if (trimmed.length === 0)
|
|
108
|
+
return 'no keys provided';
|
|
109
|
+
const existing = readKeysFile();
|
|
110
|
+
existing[provider] = trimmed;
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(configDir(), { recursive: true });
|
|
113
|
+
writeFileSync(keysPath(), JSON.stringify(existing, null, 2), 'utf8');
|
|
114
|
+
chmodSync(keysPath(), 0o600);
|
|
115
|
+
invalidateKeysCache();
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return err.message;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Prefixed model id like "groq/llama-3.3-70b-versatile". */
|
|
2
|
+
export function splitModelId(id) {
|
|
3
|
+
const idx = id.indexOf('/');
|
|
4
|
+
if (idx <= 0)
|
|
5
|
+
return { provider: 'orbitx', model: id };
|
|
6
|
+
return { provider: id.slice(0, idx), model: id.slice(idx + 1) };
|
|
7
|
+
}
|
|
8
|
+
export function joinModelId(provider, model) {
|
|
9
|
+
return `${provider}/${model}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AutoRouter } from '../llm/router.js';
|
|
2
|
+
import { ContextManager } from '../context/context-manager.js';
|
|
3
|
+
import { EventBus } from '../events.js';
|
|
4
|
+
import { AgentLoop } from '../agent/agent-loop.js';
|
|
5
|
+
/**
|
|
6
|
+
* Roles whose prompts are READ-ONLY guards. The AgentLoop's `readOnly` flag
|
|
7
|
+
* force-blocks edit_file/write_file/git_add/git_commit while still allowing
|
|
8
|
+
* run_shell (gated by the permission policy) — matching the role prompts.
|
|
9
|
+
*/
|
|
10
|
+
export const READONLY_ROLES = new Set(['architect', 'reviewer', 'security', 'researcher']);
|
|
11
|
+
export function createDispatcher(deps) {
|
|
12
|
+
let seq = 0;
|
|
13
|
+
return async (req) => {
|
|
14
|
+
const role = deps.roles.get(req.role);
|
|
15
|
+
if (!role) {
|
|
16
|
+
return { ok: false, summary: `unknown role: ${req.role} (known: ${deps.roles.list().map((r) => r.id).join(', ')})` };
|
|
17
|
+
}
|
|
18
|
+
const id = `role_${req.role}_${++seq}`;
|
|
19
|
+
deps.registerRole?.(id);
|
|
20
|
+
let ok = false;
|
|
21
|
+
let summary = '(role agent produced no summary)';
|
|
22
|
+
try {
|
|
23
|
+
const assignment = { role: req.role, task: req.task, files: req.files, cwd: deps.cwd };
|
|
24
|
+
const prompt = role.buildSystemPrompt(assignment);
|
|
25
|
+
const model = role.defaultModel ?? deps.defaultModel;
|
|
26
|
+
const candidates = model ? [model.primary, ...(model.fallback ?? [])].filter(Boolean) : [];
|
|
27
|
+
const router = new AutoRouter(deps.providers, candidates, deps.strategy);
|
|
28
|
+
deps.bus?.emit('onAgentStart', { role: req.role, task: truncate(req.task, 140) });
|
|
29
|
+
const subContext = new ContextManager(prompt);
|
|
30
|
+
const loop = new AgentLoop({
|
|
31
|
+
bus: deps.bus ?? new EventBus(),
|
|
32
|
+
router,
|
|
33
|
+
context: subContext,
|
|
34
|
+
tools: deps.registry,
|
|
35
|
+
permissions: deps.permissions,
|
|
36
|
+
systemPrompt: prompt,
|
|
37
|
+
maxIterations: deps.maxIterations ?? 12,
|
|
38
|
+
timeoutMs: deps.toolTimeoutMs ?? 30_000,
|
|
39
|
+
streamTimeoutMs: deps.streamTimeoutMs ?? 120_000,
|
|
40
|
+
contextBudgetTokens: deps.contextBudgetTokens ?? 48_000,
|
|
41
|
+
cwd: deps.cwd,
|
|
42
|
+
readOnly: READONLY_ROLES.has(req.role),
|
|
43
|
+
reasoning: deps.reasoning,
|
|
44
|
+
});
|
|
45
|
+
const res = await loop.run(req.task);
|
|
46
|
+
const last = lastAssistantMessage(subContext.history());
|
|
47
|
+
const hasSummary = Boolean(last?.content.trim());
|
|
48
|
+
summary = hasSummary ? last.content.trim() : 'role agent finished without a summary';
|
|
49
|
+
ok = !res.interrupted && hasSummary;
|
|
50
|
+
deps.bus?.emit('onAgentEnd', { role: req.role, ok, summary: truncate(summary, 200) });
|
|
51
|
+
return { ok, summary };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
summary = `role agent crashed: ${err.message}`;
|
|
55
|
+
ok = false;
|
|
56
|
+
deps.bus?.emit('onAgentEnd', { role: req.role, ok: false, summary });
|
|
57
|
+
return { ok, summary };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
deps.unregisterRole?.(id);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function lastAssistantMessage(messages) {
|
|
65
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
66
|
+
const m = messages[i];
|
|
67
|
+
if (m.role === 'assistant' && m.content.trim())
|
|
68
|
+
return m;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
export function truncate(s, n) {
|
|
73
|
+
return s.length <= n ? s : `${s.slice(0, n)}…`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { newId } from './types.js';
|
|
2
|
+
export class Messenger {
|
|
3
|
+
mailboxes = new Map();
|
|
4
|
+
pending = new Map();
|
|
5
|
+
opts;
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.opts = opts;
|
|
8
|
+
}
|
|
9
|
+
agentIds() {
|
|
10
|
+
return this.opts.getAgentIds();
|
|
11
|
+
}
|
|
12
|
+
/** Deliver a message. Returns the stored message. */
|
|
13
|
+
send(from, to, content, kind = 'message', replyTo) {
|
|
14
|
+
const ids = this.agentIds();
|
|
15
|
+
if (!ids.includes(to)) {
|
|
16
|
+
throw new Error(`unknown target agent: ${to} (known: ${ids.join(', ')} or 'user')`);
|
|
17
|
+
}
|
|
18
|
+
const msg = { id: newId(), from, to, kind, content, createdAt: Date.now(), replyTo };
|
|
19
|
+
const box = this.mailboxes.get(to) ?? [];
|
|
20
|
+
box.push(msg);
|
|
21
|
+
this.mailboxes.set(to, box);
|
|
22
|
+
this.tryResolvePending(to, msg);
|
|
23
|
+
this.opts.onMessage?.(msg);
|
|
24
|
+
return msg;
|
|
25
|
+
}
|
|
26
|
+
/** Interrupt a target: if it is running it will be aborted by the caller. */
|
|
27
|
+
interrupt(target, reason, from = 'user') {
|
|
28
|
+
return this.send(from, target, reason, 'interrupt');
|
|
29
|
+
}
|
|
30
|
+
mailboxLength(target) {
|
|
31
|
+
return this.mailboxes.get(target)?.length ?? 0;
|
|
32
|
+
}
|
|
33
|
+
hasPending(target) {
|
|
34
|
+
return this.mailboxLength(target) > 0;
|
|
35
|
+
}
|
|
36
|
+
/** Drain the target's mailbox (delivered in FIFO order). */
|
|
37
|
+
takePending(target) {
|
|
38
|
+
const box = this.mailboxes.get(target) ?? [];
|
|
39
|
+
this.mailboxes.set(target, []);
|
|
40
|
+
return box;
|
|
41
|
+
}
|
|
42
|
+
/** Peek the target's mailbox without draining it. */
|
|
43
|
+
pendingOf(target) {
|
|
44
|
+
return [...(this.mailboxes.get(target) ?? [])];
|
|
45
|
+
}
|
|
46
|
+
/** Park the caller until a reply from `replyFrom` arrives or timeout. */
|
|
47
|
+
waitForReply(from, replyFrom, timeoutMs, signal) {
|
|
48
|
+
// Bookkeeping request only — NOT delivered to the target's mailbox (a bare
|
|
49
|
+
// 'status' ping would pollute the peer's context; the waiter is resolved by
|
|
50
|
+
// the peer's real reply referencing it or by anyone-newer traffic).
|
|
51
|
+
const requestMsg = {
|
|
52
|
+
id: newId(),
|
|
53
|
+
from,
|
|
54
|
+
to: replyFrom,
|
|
55
|
+
kind: 'status',
|
|
56
|
+
content: '',
|
|
57
|
+
createdAt: Date.now(),
|
|
58
|
+
};
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const req = { id: requestMsg.id, from, requestMsg, resolve, reject, createdAt: Date.now() };
|
|
61
|
+
const list = this.pending.get(from) ?? [];
|
|
62
|
+
list.push(req);
|
|
63
|
+
this.pending.set(from, list);
|
|
64
|
+
this.opts.onParked?.(from);
|
|
65
|
+
const timer = setTimeout(() => this.settle(from, req, reject, new Error(`timeout waiting for reply from ${replyFrom}`)), timeoutMs);
|
|
66
|
+
const onAbort = () => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
signal?.removeEventListener('abort', onAbort);
|
|
69
|
+
this.settle(from, req, reject, new Error('cancelled while waiting for reply'));
|
|
70
|
+
};
|
|
71
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
settle(from, req, reject, err) {
|
|
75
|
+
const list = this.pending.get(from);
|
|
76
|
+
if (!list)
|
|
77
|
+
return;
|
|
78
|
+
const idx = list.indexOf(req);
|
|
79
|
+
if (idx < 0)
|
|
80
|
+
return;
|
|
81
|
+
list.splice(idx, 1);
|
|
82
|
+
if (list.length === 0)
|
|
83
|
+
this.pending.delete(from);
|
|
84
|
+
reject(err);
|
|
85
|
+
// only resolved by us, not a real reply — still let the driver know
|
|
86
|
+
// we shouldn't call onMessage here (no message); driver polls instead.
|
|
87
|
+
this.opts.onUnparked?.(from);
|
|
88
|
+
}
|
|
89
|
+
tryResolvePending(to, incoming) {
|
|
90
|
+
// Waiters park under the parked agent's id (the `from` of the original
|
|
91
|
+
// request). A message that lands in `to`'s mailbox resolves waiters whose
|
|
92
|
+
// parked agent == `to`, provided the sender is not the waiter itself.
|
|
93
|
+
// When the waiter attached `replyTo`, only that request matches.
|
|
94
|
+
const entries = [...this.pending.entries()];
|
|
95
|
+
let reserved = false;
|
|
96
|
+
for (const [waiterAgent, list] of entries) {
|
|
97
|
+
if (list.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
const matches = list.filter((req) => {
|
|
100
|
+
if (req.from !== waiterAgent || waiterAgent !== to)
|
|
101
|
+
return false;
|
|
102
|
+
if (incoming.from === waiterAgent)
|
|
103
|
+
return false;
|
|
104
|
+
return incoming.replyTo ? incoming.replyTo === req.id : true;
|
|
105
|
+
});
|
|
106
|
+
if (matches.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
for (const req of matches) {
|
|
109
|
+
const idx = list.indexOf(req);
|
|
110
|
+
if (idx >= 0)
|
|
111
|
+
list.splice(idx, 1);
|
|
112
|
+
req.resolve(incoming);
|
|
113
|
+
this.opts.onUnparked?.(waiterAgent);
|
|
114
|
+
}
|
|
115
|
+
reserved = true;
|
|
116
|
+
if (list.length === 0)
|
|
117
|
+
this.pending.delete(waiterAgent);
|
|
118
|
+
}
|
|
119
|
+
// The reply was consumed by a waiting parker — do not redeliver it when
|
|
120
|
+
// the agent later drains its mailbox.
|
|
121
|
+
if (reserved) {
|
|
122
|
+
const box = this.mailboxes.get(to);
|
|
123
|
+
if (box) {
|
|
124
|
+
const ix = box.indexOf(incoming);
|
|
125
|
+
if (ix >= 0)
|
|
126
|
+
box.splice(ix, 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Agents currently parked waiting on a reply. */
|
|
131
|
+
parkedAgents() {
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const [id, list] of this.pending) {
|
|
134
|
+
if (list.length > 0)
|
|
135
|
+
out.push(id);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic agent role registry. Roles are definitions — id, label, prompt
|
|
3
|
+
* builder, optional default model — NOT hardcoded switches in the runtime.
|
|
4
|
+
* Built-in roles ship here; `registerRole()` adds custom ones at runtime so
|
|
5
|
+
* the set stays extensible without touching orchestration code.
|
|
6
|
+
*/
|
|
7
|
+
function basePrompt(label, assignment) {
|
|
8
|
+
const scope = assignment.files && assignment.files.length > 0
|
|
9
|
+
? `\nRelevant files (focus here, do not wander):\n${assignment.files.map((f) => ` - ${f}`).join('\n')}`
|
|
10
|
+
: '';
|
|
11
|
+
return `You are orbit-agent's ${label} running as a terminal agent.
|
|
12
|
+
|
|
13
|
+
# Operating environment
|
|
14
|
+
Working directory: ${assignment.cwd}
|
|
15
|
+
${scope}
|
|
16
|
+
|
|
17
|
+
# Assignment
|
|
18
|
+
${assignment.task}`;
|
|
19
|
+
}
|
|
20
|
+
function makeDef(id, label, description, body) {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
label,
|
|
24
|
+
description,
|
|
25
|
+
buildSystemPrompt: (a) => `${basePrompt(label, a)}\n\n${body}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const READONLY_GUARD = `You are READ-ONLY. You cannot run shell commands or modify anything; only read, search, and inspect (including git status/diff/log).`;
|
|
29
|
+
const VERIFY_RULES = `- Run the project's checks (tests, typecheck, build) yourself before claiming success.
|
|
30
|
+
- Do not modify unrelated files. Do not commit unless the assignment says so.
|
|
31
|
+
- If genuinely blocked, say exactly what and why.`;
|
|
32
|
+
const ROLE_DEFS = [
|
|
33
|
+
makeDef('architect', 'Architect', 'Designs implementation plans. Explores the codebase and produces precise, minimal plans: exact files, exact changes, order, verification steps.', `# Your job
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
# Rules
|
|
37
|
+
- ${READONLY_GUARD}
|
|
38
|
+
- Base plans on evidence you actually read, never guesses.
|
|
39
|
+
- Prefer the smallest changes that satisfy the assignment.
|
|
40
|
+
- End with a section "## Plan" listing concrete steps with file paths.
|
|
41
|
+
- Keep the final answer under ~60 lines.`),
|
|
42
|
+
makeDef('frontend', 'Frontend Developer', 'Implements frontend / UI work (React, Vue, styling, components, UX).', `# Your job
|
|
43
|
+
You implement frontend / UI changes. Read before you edit, preserve existing conventions, and verify builds.
|
|
44
|
+
|
|
45
|
+
# Rules
|
|
46
|
+
- Read before you edit. Never write content you have not verified against the current file.
|
|
47
|
+
- Prefer edit_file over write_file for surgical changes.
|
|
48
|
+
- ${VERIFY_RULES}
|
|
49
|
+
- End with "## Summary" listing what you changed and the verification you ran.`),
|
|
50
|
+
makeDef('backend', 'Backend Developer', 'Implements backend / API / data-layer work (servers, databases, contracts).', `# Your job
|
|
51
|
+
You implement backend / API / data-layer changes. Read before you edit, preserve existing conventions, and verify builds.
|
|
52
|
+
|
|
53
|
+
# Rules
|
|
54
|
+
- Read before you edit. Never write content you have not verified against the current file.
|
|
55
|
+
- Prefer edit_file over write_file for surgical changes.
|
|
56
|
+
- ${VERIFY_RULES}
|
|
57
|
+
- End with "## Summary" listing what you changed and the verification you ran.`),
|
|
58
|
+
makeDef('researcher', 'Researcher', 'Investigates code, docs, and external info. Returns findings, not edits.', `# Your job
|
|
59
|
+
Investigate and report back findings: how parts fit together, what a library does, current behavior, risks. Do NOT implement changes.
|
|
60
|
+
|
|
61
|
+
# Rules
|
|
62
|
+
- READ-ONLY for modifications; you may run shell commands to inspect (tests, git grep already available).
|
|
63
|
+
- Cite evidence: file:line, command output, or doc quotes. Never guess.
|
|
64
|
+
- Keep the final answer focused and under ~50 lines.`),
|
|
65
|
+
makeDef('debugger', 'Debugger', 'Reproduces bugs, narrows the root cause, and reports it with a prescribed fix.', `# Your job
|
|
66
|
+
Reproduce the bug, narrow the root cause, and report it precisely with a recommended fix. You may add temporary diagnostics if needed, but focus on root-cause analysis.
|
|
67
|
+
|
|
68
|
+
# Rules
|
|
69
|
+
- Reproduce first. Capture the failing command and output verbatim.
|
|
70
|
+
- Use bisecting/greps to isolate the cause. Verify your hypothesis with an experiment.
|
|
71
|
+
- Report root cause with file:line and a concrete recommended fix. If you are unsure, say so.
|
|
72
|
+
- End with "## Diagnosis" and "## Recommended fix". Keep it under ~50 lines.`),
|
|
73
|
+
makeDef('tester', 'Tester', 'Writes and runs tests for the given change and reports coverage gaps.', `# Your job
|
|
74
|
+
Write and run tests covering the given change. Report results and coverage gaps.
|
|
75
|
+
|
|
76
|
+
# Rules
|
|
77
|
+
- Read the project's test setup before writing tests; follow its conventions exactly.
|
|
78
|
+
- Run the full relevant suite and report pass/fail counts verbatim.
|
|
79
|
+
- Do not modify production code unless the assignment explicitly says so.
|
|
80
|
+
- End with "## Test results" and "## Gaps".`),
|
|
81
|
+
makeDef('reviewer', 'Reviewer', 'Independently reviews changes against the assignment and returns a PASS/FAIL verdict.', `# Your job
|
|
82
|
+
Independently review the changes against the assignment. Find real problems: bugs, regressions, architectural issues, missing tests, style drift. Do NOT rubber-stamp.
|
|
83
|
+
|
|
84
|
+
# Rules
|
|
85
|
+
- You are READ-ONLY for file contents (no edit_file/write_file) but MAY run shell commands to verify (tests, typecheck, diff, log).
|
|
86
|
+
- Verify claims: if code was changed, run the relevant checks and quote the output.
|
|
87
|
+
- Report concrete, actionable findings: file:line where possible, and the exact fix.
|
|
88
|
+
- End with "## Verdict": PASS or FAIL, and if FAIL, a short list of required fixes.`),
|
|
89
|
+
makeDef('security', 'Security Reviewer', 'Reviews changes for security issues (auth, injection, secrets, untrusted input).', `# Your job
|
|
90
|
+
Review the given code for security issues: injection, broken auth/z, secret handling, unsafe deserialization, path traversal, SSRF, denial-of-service.
|
|
91
|
+
|
|
92
|
+
# Rules
|
|
93
|
+
- READ-ONLY; you may run inspection commands (grep, git diff/log) to trace data flow.
|
|
94
|
+
- For each finding include severity (critical/high/medium/low), file:line, exploit sketch, and the concrete fix.
|
|
95
|
+
- End with "## Verdict": PASS or FAIL, and if FAIL a short list of required fixes.`),
|
|
96
|
+
];
|
|
97
|
+
const DEFAULT_ROLE_MODELS = {};
|
|
98
|
+
export class RoleRegistry {
|
|
99
|
+
roles = new Map();
|
|
100
|
+
constructor() {
|
|
101
|
+
for (const def of ROLE_DEFS) {
|
|
102
|
+
const d = DEFAULT_ROLE_MODELS[def.id];
|
|
103
|
+
this.register(def.id, def.label, def.description, def.buildSystemPrompt, d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
register(id, label, description, buildSystemPrompt, defaultModel) {
|
|
107
|
+
this.roles.set(id, { id, label, description, buildSystemPrompt, defaultModel });
|
|
108
|
+
}
|
|
109
|
+
registerRole(def) {
|
|
110
|
+
const existing = this.roles.get(def.id);
|
|
111
|
+
this.roles.set(def.id, { ...existing, ...def });
|
|
112
|
+
}
|
|
113
|
+
get(roleId) {
|
|
114
|
+
return this.roles.get(roleId);
|
|
115
|
+
}
|
|
116
|
+
has(roleId) {
|
|
117
|
+
return this.roles.has(roleId);
|
|
118
|
+
}
|
|
119
|
+
all() {
|
|
120
|
+
return [...this.roles.values()];
|
|
121
|
+
}
|
|
122
|
+
buildSystemPrompt(roleId, assignment) {
|
|
123
|
+
return this.roles.get(roleId)?.buildSystemPrompt(assignment) ?? null;
|
|
124
|
+
}
|
|
125
|
+
list() {
|
|
126
|
+
return this.all().map((d) => ({ id: d.id, label: d.label, description: d.description }));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export const builtInRoleIds = ROLE_DEFS.map((d) => d.id);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AutoRouter } from '../llm/router.js';
|
|
2
|
+
import { ContextManager } from '../context/context-manager.js';
|
|
3
|
+
import { EventBus } from '../events.js';
|
|
4
|
+
import { AgentLoop } from '../agent/agent-loop.js';
|
|
5
|
+
const FORWARDED_EVENTS = [
|
|
6
|
+
'onUserMessage',
|
|
7
|
+
'onAssistantStart',
|
|
8
|
+
'onThinking',
|
|
9
|
+
'onToken',
|
|
10
|
+
'onToolStart',
|
|
11
|
+
'onToolUpdate',
|
|
12
|
+
'onToolEnd',
|
|
13
|
+
'onAssistantEnd',
|
|
14
|
+
'onError',
|
|
15
|
+
'onRoute',
|
|
16
|
+
'onStatus',
|
|
17
|
+
'onAgentStart',
|
|
18
|
+
'onAgentEnd',
|
|
19
|
+
'onContextSummary',
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* AgentRuntime — one full agent in an orchestration session.
|
|
23
|
+
* Owns a per-agent router (per-agent model assignment), an isolated
|
|
24
|
+
* ContextManager, its own EventBus that fans out to the public session bus,
|
|
25
|
+
* and an AgentLoop it can (re)start and abort per turn.
|
|
26
|
+
*/
|
|
27
|
+
export class AgentRuntime {
|
|
28
|
+
opts;
|
|
29
|
+
identity;
|
|
30
|
+
bus = new EventBus();
|
|
31
|
+
router;
|
|
32
|
+
context;
|
|
33
|
+
loop = null;
|
|
34
|
+
constructor(opts) {
|
|
35
|
+
this.opts = opts;
|
|
36
|
+
this.identity = opts.identity;
|
|
37
|
+
this.router = new AutoRouter(opts.providers, concatCandidates(opts.model), opts.strategy);
|
|
38
|
+
this.context = new ContextManager(opts.systemPrompt);
|
|
39
|
+
if (opts.bus) {
|
|
40
|
+
for (const name of FORWARDED_EVENTS) {
|
|
41
|
+
this.bus.on(name, (arg) => opts.bus.emit(name, arg));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
get label() {
|
|
46
|
+
return this.opts.label;
|
|
47
|
+
}
|
|
48
|
+
get modelId() {
|
|
49
|
+
return this.opts.model.primary;
|
|
50
|
+
}
|
|
51
|
+
get candidateList() {
|
|
52
|
+
return this.router.candidateList;
|
|
53
|
+
}
|
|
54
|
+
/** True while a loop is running (active or parked waiting for a reply). */
|
|
55
|
+
get isRunning() {
|
|
56
|
+
return this.loop !== null;
|
|
57
|
+
}
|
|
58
|
+
get mail() {
|
|
59
|
+
return this.opts.messenger.mailboxLength(this.identity.id);
|
|
60
|
+
}
|
|
61
|
+
async run(input) {
|
|
62
|
+
this.loop = new AgentLoop(this.loopOpts());
|
|
63
|
+
try {
|
|
64
|
+
return await this.loop.run(input);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
this.loop = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
abort() {
|
|
71
|
+
this.loop?.abort();
|
|
72
|
+
}
|
|
73
|
+
loopOpts() {
|
|
74
|
+
const o = this.opts;
|
|
75
|
+
return {
|
|
76
|
+
bus: this.bus,
|
|
77
|
+
router: this.router,
|
|
78
|
+
context: this.context,
|
|
79
|
+
tools: o.tools,
|
|
80
|
+
permissions: o.permissions,
|
|
81
|
+
systemPrompt: o.systemPrompt,
|
|
82
|
+
maxIterations: o.maxIterations ?? 16,
|
|
83
|
+
maxDelegations: o.maxDelegations ?? 6,
|
|
84
|
+
contextBudgetTokens: o.contextBudgetTokens ?? 48_000,
|
|
85
|
+
timeoutMs: o.toolTimeoutMs ?? 30_000,
|
|
86
|
+
streamTimeoutMs: o.streamTimeoutMs ?? 120_000,
|
|
87
|
+
cwd: o.cwd,
|
|
88
|
+
readOnly: o.readOnly ?? false,
|
|
89
|
+
delegate: o.delegate,
|
|
90
|
+
maxTokensPerSecond: o.maxTokensPerSecond,
|
|
91
|
+
reasoning: o.reasoning,
|
|
92
|
+
toolContextExtras: this.toolContextExtras(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
toolContextExtras() {
|
|
96
|
+
const id = this.identity.id;
|
|
97
|
+
const { messenger, shared } = this.opts;
|
|
98
|
+
return {
|
|
99
|
+
agent: {
|
|
100
|
+
send: (to, content, kind = 'message') => {
|
|
101
|
+
const msg = messenger.send(id, to, content, kind);
|
|
102
|
+
return String(msg.id);
|
|
103
|
+
},
|
|
104
|
+
waitForReply: (from, timeoutMs) => messenger.waitForReply(id, from, timeoutMs, this.loop?.runSignal).then((m) => m.content),
|
|
105
|
+
interrupt: (target, reason) => {
|
|
106
|
+
messenger.interrupt(target, reason, id);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
shared: {
|
|
110
|
+
getFacts: () => shared.getFacts(),
|
|
111
|
+
addFact: (fact) => {
|
|
112
|
+
shared.addFact(fact);
|
|
113
|
+
},
|
|
114
|
+
getJournal: (depth) => shared.getJournal(depth),
|
|
115
|
+
getExcerptForSelf: () => shared.excerptFor(id),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function concatCandidates(model) {
|
|
121
|
+
return [model.primary, ...(model.fallback ?? [])].filter(Boolean);
|
|
122
|
+
}
|