@bahulam/code 0.1.11 → 0.1.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/package.json +1 -1
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/config/cli-args.mjs +16 -0
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +54 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +95 -15
- package/src/core/tool-executor.mjs +219 -15
- package/src/local-service/agent-relay.mjs +1 -1
- package/src/local-service/server.mjs +116 -14
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +2 -2
- package/src/plugins/manifest.mjs +27 -27
- package/src/plugins/preflight.mjs +8 -8
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +8 -2
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +572 -96
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +88 -4
- package/src/ui/slash-commands.mjs +1 -1
- package/src/ui/sub-agent.mjs +14 -8
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { LocalAgent } from '../core/local-agent.mjs';
|
|
2
|
+
import { backgroundTasks } from '../core/background-tasks.mjs';
|
|
3
|
+
import { classifyShell, TIERS } from '../core/risk-tier.mjs';
|
|
4
|
+
import { createScopedToolExecutor } from '../terminal/agents.mjs';
|
|
5
|
+
import { namespaceNodeEvents } from './events.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Execute one agent node. Substrates:
|
|
9
|
+
* 'direct' — a per-node LocalAgent instance calling the model API
|
|
10
|
+
* directly. One instance per node makes the node's resolved
|
|
11
|
+
* model deterministic (LocalAgent fixes model at construction).
|
|
12
|
+
* 'session' — an injected adapter (ctx.sessionSubstrate) that runs the
|
|
13
|
+
* node through the logged-in backend flow. The engine stays
|
|
14
|
+
* terminal-agnostic; the REPL provides the adapter.
|
|
15
|
+
*
|
|
16
|
+
* Every tool call flows through a scoped executor that enforces the
|
|
17
|
+
* node's effective allowlist and tags calls internal/subAgent, so hooks,
|
|
18
|
+
* plugin-tool gating, and attribution behave exactly like direct agent
|
|
19
|
+
* runs today.
|
|
20
|
+
*/
|
|
21
|
+
export async function* runNode(node, agent, instruction, ctx, options = {}) {
|
|
22
|
+
if (node.type === 'job') {
|
|
23
|
+
yield* namespaceNodeEvents(runJobNode(node, ctx, options), { node, runId: options.runId });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (node.type === 'service') {
|
|
27
|
+
yield* namespaceNodeEvents(runServiceNode(node, ctx, options), { node, runId: options.runId });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const effectiveAgent = withEffectiveTools(node, agent);
|
|
31
|
+
const scopedExecutor = createScopedToolExecutor(ctx.toolExecutor, effectiveAgent, {
|
|
32
|
+
projectRoot: ctx.cwd || process.cwd(),
|
|
33
|
+
});
|
|
34
|
+
const substrate = selectSubstrate(ctx, options);
|
|
35
|
+
|
|
36
|
+
if (substrate === 'session') {
|
|
37
|
+
yield* namespaceNodeEvents(
|
|
38
|
+
ctx.sessionSubstrate(effectiveAgent, node, instruction, { scopedExecutor }),
|
|
39
|
+
{ node, runId: options.runId },
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const model = node.model || effectiveAgent.model || ctx.defaultModel || null;
|
|
45
|
+
const { apiKey = null, openRouterKey = null } = ctx.credentials || {};
|
|
46
|
+
if (!apiKey && !openRouterKey) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Cannot run node '${node.id}' locally: no model API key. ` +
|
|
49
|
+
'Set ANTHROPIC_API_KEY or OPENROUTER_API_KEY, or log in and use the session substrate.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Plugin tools the agent declares need their schemas in the model's
|
|
54
|
+
// tool list (built-ins are hardcoded in LocalAgent; plugin schemas are
|
|
55
|
+
// dynamic). Execution still flows through the scoped executor.
|
|
56
|
+
const declaredTools = new Set(
|
|
57
|
+
(Array.isArray(effectiveAgent.tools) ? effectiveAgent.tools : []).map(String),
|
|
58
|
+
);
|
|
59
|
+
const extraToolSchemas = (ctx.toolExecutor?.listPluginToolSchemas?.() || [])
|
|
60
|
+
.filter(schema => declaredTools.has(schema.name));
|
|
61
|
+
|
|
62
|
+
const localAgent = new LocalAgent({
|
|
63
|
+
apiKey,
|
|
64
|
+
openRouterKey,
|
|
65
|
+
model,
|
|
66
|
+
toolExecutor: scopedExecutor,
|
|
67
|
+
cwd: ctx.cwd || process.cwd(),
|
|
68
|
+
systemPromptOverride: effectiveAgent.prompt || effectiveAgent.system_prompt || null,
|
|
69
|
+
maxTurns: effectiveAgent.max_iterations || null,
|
|
70
|
+
extraToolSchemas,
|
|
71
|
+
});
|
|
72
|
+
if (options.signal) options.signal.addEventListener('abort', () => localAgent.cancel?.(), { once: true });
|
|
73
|
+
|
|
74
|
+
yield* namespaceNodeEvents(localAgent.execute(instruction, {}), { node, runId: options.runId });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A job node runs a process instead of an LLM. It blocks its graph edge
|
|
78
|
+
// until the process exits (graph-level parallelism provides concurrency),
|
|
79
|
+
// and its handoff is the exit code plus the output tail. Commands are
|
|
80
|
+
// risk-classified up front — dangerous patterns fail the node
|
|
81
|
+
// deterministically rather than prompting mid-graph.
|
|
82
|
+
async function* runJobNode(node, ctx, options = {}) {
|
|
83
|
+
const command = String(node.command || '').trim();
|
|
84
|
+
const tier = classifyShell(command);
|
|
85
|
+
if (tier === TIERS.SHELL_DANGEROUS) {
|
|
86
|
+
throw new Error(`Job '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const started = backgroundTasks.start({
|
|
90
|
+
command,
|
|
91
|
+
cwd: ctx.cwd || process.cwd(),
|
|
92
|
+
timeoutMs: node.timeout_s ? node.timeout_s * 1000 : undefined,
|
|
93
|
+
name: node.id,
|
|
94
|
+
});
|
|
95
|
+
yield { type: 'status', data: { message: `job ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
|
|
96
|
+
if (options.signal) {
|
|
97
|
+
options.signal.addEventListener('abort', () => backgroundTasks.kill(started.id), { once: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const done = await backgroundTasks.wait(started.id);
|
|
101
|
+
const ok = done.status === 'completed';
|
|
102
|
+
const summaryLine = `job ${node.id} ${done.status} (exit ${done.exit_code ?? 'n/a'}, ${done.duration_s}s)`;
|
|
103
|
+
const tail = String(done.tail || '').trim();
|
|
104
|
+
if (!ok) {
|
|
105
|
+
throw new Error(`${summaryLine}${tail ? `\n${tail.slice(-2000)}` : ''}`);
|
|
106
|
+
}
|
|
107
|
+
yield {
|
|
108
|
+
type: 'complete',
|
|
109
|
+
data: {
|
|
110
|
+
final_response: `${summaryLine}${tail ? `\n${tail}` : ''}`,
|
|
111
|
+
job_id: done.id,
|
|
112
|
+
exit_code: done.exit_code,
|
|
113
|
+
log_path: done.log_path,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A service node starts a long-lived process (dev server) and completes
|
|
119
|
+
// at READINESS, not exit — downstream nodes run against the live service.
|
|
120
|
+
// The process is registered in options.serviceJobs so the graph runner
|
|
121
|
+
// kills it when the run ends. Readiness: ready.port (TCP connect),
|
|
122
|
+
// ready.log_pattern (regex on output tail), or ready.delay_s (default 2s).
|
|
123
|
+
async function* runServiceNode(node, ctx, options = {}) {
|
|
124
|
+
const command = String(node.command || '').trim();
|
|
125
|
+
const tier = classifyShell(command);
|
|
126
|
+
if (tier === TIERS.SHELL_DANGEROUS) {
|
|
127
|
+
throw new Error(`Service '${node.id}' blocked: command matches a high-risk pattern (${command.slice(0, 80)})`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const started = backgroundTasks.start({
|
|
131
|
+
command,
|
|
132
|
+
cwd: ctx.cwd || process.cwd(),
|
|
133
|
+
timeoutMs: 0, // services live until the graph run ends
|
|
134
|
+
name: node.id,
|
|
135
|
+
});
|
|
136
|
+
if (Array.isArray(options.serviceJobs)) options.serviceJobs.push(started.id);
|
|
137
|
+
yield { type: 'status', data: { message: `service ${node.id}: ${command.slice(0, 80)} (${started.id})` } };
|
|
138
|
+
|
|
139
|
+
const ready = node.ready || {};
|
|
140
|
+
const timeoutMs = (Number(ready.timeout_s) || 60) * 1000;
|
|
141
|
+
const deadline = Date.now() + timeoutMs;
|
|
142
|
+
const poll = async () => {
|
|
143
|
+
const job = backgroundTasks.describe(started.id);
|
|
144
|
+
if (!job || job.status !== 'running') {
|
|
145
|
+
throw new Error(`service ${node.id} exited before becoming ready (${job?.status}, exit ${job?.exit_code})\n${String(job?.tail || '').slice(-1000)}`);
|
|
146
|
+
}
|
|
147
|
+
if (ready.port) return await portOpen(Number(ready.port));
|
|
148
|
+
if (ready.log_pattern) return new RegExp(ready.log_pattern).test(job.tail || '');
|
|
149
|
+
return true; // no probe declared → ready after the initial delay
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await new Promise(resolve => setTimeout(resolve, (Number(ready.delay_s) || 2) * 1000));
|
|
153
|
+
while (!(await poll())) {
|
|
154
|
+
if (Date.now() > deadline) {
|
|
155
|
+
backgroundTasks.kill(started.id);
|
|
156
|
+
throw new Error(`service ${node.id} not ready within ${timeoutMs / 1000}s`);
|
|
157
|
+
}
|
|
158
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
yield {
|
|
162
|
+
type: 'complete',
|
|
163
|
+
data: {
|
|
164
|
+
final_response: `service ${node.id} ready (${started.id}${ready.port ? `, port ${ready.port}` : ''}). It stays up for the rest of this run.`,
|
|
165
|
+
job_id: started.id,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function portOpen(port) {
|
|
171
|
+
const { Socket } = await import('node:net');
|
|
172
|
+
return new Promise(resolve => {
|
|
173
|
+
const socket = new Socket();
|
|
174
|
+
const done = (ok) => { socket.destroy(); resolve(ok); };
|
|
175
|
+
socket.setTimeout(750, () => done(false));
|
|
176
|
+
socket.once('error', () => done(false));
|
|
177
|
+
socket.connect(port, '127.0.0.1', () => done(true));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function selectSubstrate(ctx, options) {
|
|
182
|
+
const requested = options.substrate || 'auto';
|
|
183
|
+
if (requested === 'session' || requested === 'direct') return requested;
|
|
184
|
+
return typeof ctx.sessionSubstrate === 'function' && ctx.auth?.token ? 'session' : 'direct';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// node.tools, when present, narrows the agent's own allowlist (intersection).
|
|
188
|
+
function withEffectiveTools(node, agent) {
|
|
189
|
+
const agentTools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
190
|
+
if (!Array.isArray(node.tools) || !node.tools.length) return agent;
|
|
191
|
+
const narrowed = new Set(node.tools);
|
|
192
|
+
return { ...agent, tools: agentTools.filter(tool => narrowed.has(tool)) };
|
|
193
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { backgroundTasks } from '../core/background-tasks.mjs';
|
|
3
|
+
import { normalizeGraphSpec, validateGraph } from './graph.mjs';
|
|
4
|
+
import { deriveApprovalScope } from './approval.mjs';
|
|
5
|
+
import { runNode } from './node-runner.mjs';
|
|
6
|
+
import { graphEvent } from './events.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a graph of agent nodes locally, in-process. Yields the same
|
|
10
|
+
* event vocabulary the main renderer already consumes, plus lifecycle
|
|
11
|
+
* events: graph_run_start, graph_node_start, graph_node_result,
|
|
12
|
+
* graph_run_result.
|
|
13
|
+
*
|
|
14
|
+
* pattern: 'sequential' runs nodes one at a time in topological order.
|
|
15
|
+
* pattern: 'parallel' groups nodes into dependency levels — nodes in the
|
|
16
|
+
* same level (no path between them) execute concurrently; their events
|
|
17
|
+
* are buffered and flushed contiguously in topological order so the
|
|
18
|
+
* output stays deterministic while wall-clock shrinks. Handoffs are
|
|
19
|
+
* snapshotted at level start: same-level nodes never see each other,
|
|
20
|
+
* only completed upstream levels.
|
|
21
|
+
*/
|
|
22
|
+
export async function* runGraph(graphSpec, triggerInput = {}, ctx = {}, options = {}) {
|
|
23
|
+
const spec = normalizeGraphSpec(graphSpec);
|
|
24
|
+
const agentNodes = validateGraph(spec);
|
|
25
|
+
const runId = options.runId || `gr-${crypto.randomUUID().slice(0, 12)}`;
|
|
26
|
+
|
|
27
|
+
const resolveAgent = ctx.resolveAgent || (() => null);
|
|
28
|
+
for (const node of agentNodes) {
|
|
29
|
+
if (node.type === 'job' || node.type === 'service') continue;
|
|
30
|
+
if (!node.agent) node.agent = resolveAgent(node.agent_slug);
|
|
31
|
+
if (!node.agent) throw new Error(`Unknown agent '${node.agent_slug}' in graph '${spec.name}'`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const approvalScope = deriveApprovalScope(agentNodes, resolveAgent);
|
|
35
|
+
yield graphEvent('graph_run_start', runId, {
|
|
36
|
+
graph: spec.name,
|
|
37
|
+
pattern: spec.pattern,
|
|
38
|
+
node_count: agentNodes.length,
|
|
39
|
+
approval_scope: approvalScope,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const levels = spec.pattern === 'parallel'
|
|
43
|
+
? dependencyLevels(agentNodes, spec.edges)
|
|
44
|
+
: agentNodes.map(node => [node]);
|
|
45
|
+
|
|
46
|
+
const nodeResults = [];
|
|
47
|
+
const handoffs = [];
|
|
48
|
+
let status = 'completed';
|
|
49
|
+
// Service nodes register their process here; the run is their lifetime
|
|
50
|
+
// scope — everything still running is killed when the graph ends,
|
|
51
|
+
// whatever path it ends by (finally covers errors and early consumer
|
|
52
|
+
// abandonment too).
|
|
53
|
+
const serviceJobs = [];
|
|
54
|
+
const nodeOptions = { ...options, runId, serviceJobs };
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
for (const level of levels) {
|
|
58
|
+
if (status === 'failed') break;
|
|
59
|
+
const levelHandoffs = [...handoffs];
|
|
60
|
+
|
|
61
|
+
for (const node of level) {
|
|
62
|
+
yield graphEvent('graph_node_start', runId, {
|
|
63
|
+
node_id: node.id,
|
|
64
|
+
agent: node.agent_slug || node.id,
|
|
65
|
+
parallel_group: level.length > 1 ? level.map(n => n.id) : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Solo level: stream events live (no buffering) — this is the /run
|
|
70
|
+
// and sequential-pattern hot path.
|
|
71
|
+
if (level.length === 1) {
|
|
72
|
+
const node = level[0];
|
|
73
|
+
const instruction = buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params);
|
|
74
|
+
let output = '';
|
|
75
|
+
let accumulated = '';
|
|
76
|
+
let nodeStatus = 'completed';
|
|
77
|
+
try {
|
|
78
|
+
for await (const event of runNode(node, node.agent, instruction, ctx, nodeOptions)) {
|
|
79
|
+
if (event.type === 'content' || event.type === 'content_partial') {
|
|
80
|
+
accumulated += event.data?.text || event.data?.content || '';
|
|
81
|
+
}
|
|
82
|
+
if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
|
|
83
|
+
output = event.data.final_response;
|
|
84
|
+
}
|
|
85
|
+
yield event;
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
nodeStatus = 'failed';
|
|
89
|
+
output = err?.message || String(err);
|
|
90
|
+
}
|
|
91
|
+
if (!output && nodeStatus === 'completed') output = accumulated;
|
|
92
|
+
const result = { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output };
|
|
93
|
+
nodeResults.push(result);
|
|
94
|
+
yield graphEvent('graph_node_result', runId, result);
|
|
95
|
+
if (result.status === 'failed' && !node.continue_on_error) {
|
|
96
|
+
status = 'failed';
|
|
97
|
+
} else if (result.status === 'completed' && result.output) {
|
|
98
|
+
handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Parallel level: start every node concurrently; drain buffers in
|
|
104
|
+
// topological order so events stay contiguous per node.
|
|
105
|
+
const runs = level.map(node => collectNodeRun(
|
|
106
|
+
node,
|
|
107
|
+
buildNodeInstruction(node, triggerInput, levelHandoffs, spec.global_params),
|
|
108
|
+
ctx,
|
|
109
|
+
nodeOptions,
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < level.length; i++) {
|
|
113
|
+
const { events, result } = await runs[i];
|
|
114
|
+
for (const event of events) yield event;
|
|
115
|
+
nodeResults.push(result);
|
|
116
|
+
yield graphEvent('graph_node_result', runId, result);
|
|
117
|
+
if (result.status === 'failed' && !level[i].continue_on_error) {
|
|
118
|
+
status = 'failed';
|
|
119
|
+
} else if (result.status === 'completed' && result.output) {
|
|
120
|
+
handoffs.push({ node_id: result.node_id, agent: result.agent, output: result.output });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
} finally {
|
|
126
|
+
for (const id of serviceJobs) {
|
|
127
|
+
try { backgroundTasks.kill(id); } catch { /* already gone */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = {
|
|
132
|
+
run_id: runId,
|
|
133
|
+
graph: spec.name,
|
|
134
|
+
status,
|
|
135
|
+
node_results: nodeResults,
|
|
136
|
+
output: nodeResults.length ? nodeResults[nodeResults.length - 1].output : '',
|
|
137
|
+
};
|
|
138
|
+
yield graphEvent('graph_run_result', runId, result);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function collectNodeRun(node, instruction, ctx, options) {
|
|
143
|
+
const events = [];
|
|
144
|
+
let output = '';
|
|
145
|
+
let accumulated = '';
|
|
146
|
+
let nodeStatus = 'completed';
|
|
147
|
+
try {
|
|
148
|
+
for await (const event of runNode(node, node.agent, instruction, ctx, options)) {
|
|
149
|
+
if (event.type === 'content' || event.type === 'content_partial') {
|
|
150
|
+
accumulated += event.data?.text || event.data?.content || '';
|
|
151
|
+
}
|
|
152
|
+
if (event.type === 'complete' && typeof event.data?.final_response === 'string') {
|
|
153
|
+
output = event.data.final_response;
|
|
154
|
+
}
|
|
155
|
+
events.push(event);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
nodeStatus = 'failed';
|
|
159
|
+
output = err?.message || String(err);
|
|
160
|
+
}
|
|
161
|
+
if (!output && nodeStatus === 'completed') output = accumulated;
|
|
162
|
+
return {
|
|
163
|
+
events,
|
|
164
|
+
result: { node_id: node.id, agent: node.agent_slug || node.id, status: nodeStatus, output },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Group agent nodes by dependency depth: level N nodes depend only on
|
|
169
|
+
// nodes in levels < N. agentNodes arrive topologically ordered, so each
|
|
170
|
+
// node's predecessors are resolved before it.
|
|
171
|
+
function dependencyLevels(agentNodes, edges) {
|
|
172
|
+
const ids = new Set(agentNodes.map(node => node.id));
|
|
173
|
+
const preds = new Map(agentNodes.map(node => [node.id, []]));
|
|
174
|
+
for (const edge of edges) {
|
|
175
|
+
if (ids.has(edge.source) && ids.has(edge.target)) preds.get(edge.target).push(edge.source);
|
|
176
|
+
}
|
|
177
|
+
const depth = new Map();
|
|
178
|
+
const levels = [];
|
|
179
|
+
for (const node of agentNodes) {
|
|
180
|
+
const upstream = preds.get(node.id);
|
|
181
|
+
const d = upstream.length ? 1 + Math.max(...upstream.map(id => depth.get(id) ?? 0)) : 0;
|
|
182
|
+
depth.set(node.id, d);
|
|
183
|
+
(levels[d] ||= []).push(node);
|
|
184
|
+
}
|
|
185
|
+
return levels;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildNodeInstruction(node, triggerInput, handoffs, globalParams) {
|
|
189
|
+
const parts = [node.prompt || triggerInput.instruction || ''];
|
|
190
|
+
if (globalParams && Object.keys(globalParams).length) {
|
|
191
|
+
parts.push(`Parameters:\n${JSON.stringify(globalParams, null, 2)}`);
|
|
192
|
+
}
|
|
193
|
+
if (handoffs.length) {
|
|
194
|
+
const upstream = handoffs
|
|
195
|
+
.map(h => `--- handoff from ${h.agent} ---\n${h.output}`)
|
|
196
|
+
.join('\n\n');
|
|
197
|
+
parts.push(`Results from earlier steps:\n\n${upstream}`);
|
|
198
|
+
}
|
|
199
|
+
return parts.filter(Boolean).join('\n\n');
|
|
200
|
+
}
|
package/src/plugins/executor.mjs
CHANGED
|
@@ -73,8 +73,8 @@ export async function createPluginToolExecutor(manifest, opts = {}) {
|
|
|
73
73
|
const pluginName = manifest.metadata?.name || '';
|
|
74
74
|
|
|
75
75
|
for (const toolDef of tools) {
|
|
76
|
-
if (!toolDef.
|
|
77
|
-
const handler = await loadPluginTool(pluginDir, toolDef.
|
|
76
|
+
if (!toolDef.tool) continue;
|
|
77
|
+
const handler = await loadPluginTool(pluginDir, toolDef.tool);
|
|
78
78
|
if (handler) {
|
|
79
79
|
handlers.set(toolDef.name, { handler, toolDef });
|
|
80
80
|
}
|
package/src/plugins/manifest.mjs
CHANGED
|
@@ -26,40 +26,40 @@ function normalizeToolNames(value) {
|
|
|
26
26
|
}).filter(Boolean);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
29
|
+
function loadAgentFile(agentDef, pluginDir) {
|
|
30
|
+
const file = String(agentDef.file || agentDef.handler || '').trim();
|
|
31
|
+
if (!file || !pluginDir) return {};
|
|
32
32
|
try {
|
|
33
|
-
const
|
|
34
|
-
const raw = fs.readFileSync(
|
|
35
|
-
return path.extname(
|
|
33
|
+
const filePath = path.resolve(pluginDir, file);
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
return path.extname(filePath).toLowerCase() === '.json'
|
|
36
36
|
? JSON.parse(raw)
|
|
37
37
|
: parseYaml(raw);
|
|
38
38
|
} catch (err) {
|
|
39
39
|
if (process.env.DEBUG) {
|
|
40
|
-
console.error(`Failed to load plugin agent
|
|
40
|
+
console.error(`Failed to load plugin agent file ${file}: ${err.message}`);
|
|
41
41
|
}
|
|
42
42
|
return {};
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function normalizeAgentDef(agentDef, pluginName, pluginDir) {
|
|
47
|
-
const
|
|
48
|
-
const metadata =
|
|
49
|
-
const agent =
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
||
|
|
47
|
+
const fileConfig = loadAgentFile(agentDef, pluginDir);
|
|
48
|
+
const metadata = fileConfig.metadata || fileConfig.meta || {};
|
|
49
|
+
const agent = fileConfig.agent || fileConfig.spec?.agent || {};
|
|
50
|
+
const fileTools = (
|
|
51
|
+
fileConfig.tools
|
|
52
|
+
|| fileConfig.spec?.tools
|
|
53
53
|
|| agent.tools
|
|
54
54
|
|| []
|
|
55
55
|
);
|
|
56
56
|
const inlineTools = normalizeToolNames(agentDef.tools);
|
|
57
57
|
|
|
58
58
|
return {
|
|
59
|
-
slug: agentDef.slug || metadata.slug ||
|
|
60
|
-
name: agentDef.name || metadata.name ||
|
|
61
|
-
description: agentDef.description || metadata.description ||
|
|
62
|
-
role: agentDef.role || metadata.role ||
|
|
59
|
+
slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
|
|
60
|
+
name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
|
|
61
|
+
description: agentDef.description || metadata.description || fileConfig.description || '',
|
|
62
|
+
role: agentDef.role || metadata.role || fileConfig.role || 'specialist',
|
|
63
63
|
system_prompt: (
|
|
64
64
|
agentDef.system_prompt
|
|
65
65
|
|| agentDef.systemPrompt
|
|
@@ -67,16 +67,16 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
|
|
|
67
67
|
|| agent.system_prompt
|
|
68
68
|
|| agent.systemPrompt
|
|
69
69
|
|| agent.prompt
|
|
70
|
-
||
|
|
71
|
-
||
|
|
70
|
+
|| fileConfig.system_prompt
|
|
71
|
+
|| fileConfig.prompt
|
|
72
72
|
|| ''
|
|
73
73
|
),
|
|
74
|
-
tools: inlineTools.length ? inlineTools : normalizeToolNames(
|
|
75
|
-
model: agentDef.model || agent.model ||
|
|
76
|
-
models: agentDef.models || agent.models ||
|
|
77
|
-
max_tokens: agentDef.max_tokens || agent.max_tokens ||
|
|
78
|
-
max_iterations: agentDef.max_iterations || agent.max_iterations ||
|
|
79
|
-
|
|
74
|
+
tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
|
|
75
|
+
model: agentDef.model || agent.model || fileConfig.model || null,
|
|
76
|
+
models: agentDef.models || agent.models || fileConfig.models || undefined,
|
|
77
|
+
max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
|
|
78
|
+
max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
|
|
79
|
+
file: agentDef.file || agentDef.handler || '',
|
|
80
80
|
source: `plugin:${pluginName}`,
|
|
81
81
|
source_scope: 'plugin',
|
|
82
82
|
};
|
|
@@ -164,7 +164,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
164
164
|
name: toolDef.name || '',
|
|
165
165
|
description: toolDef.description || '',
|
|
166
166
|
input_schema: toolDef.parameters || toolDef.input_schema || toolDef.inputSchema || { type: 'object', properties: {} },
|
|
167
|
-
|
|
167
|
+
tool: toolDef.tool || toolDef.file || toolDef.handler || '',
|
|
168
168
|
plugin_name: name,
|
|
169
169
|
};
|
|
170
170
|
if (tool.name) tools.push(tool);
|
|
@@ -277,7 +277,7 @@ export function validatePluginManifest(manifest) {
|
|
|
277
277
|
if (manifest.spec) {
|
|
278
278
|
for (const tool of (manifest.spec.tools || [])) {
|
|
279
279
|
if (!tool.name) errors.push('Tool missing name');
|
|
280
|
-
if (!tool.
|
|
280
|
+
if (!tool.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
|
|
281
281
|
}
|
|
282
282
|
for (const agent of (manifest.spec.agents || [])) {
|
|
283
283
|
if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
|
|
@@ -117,23 +117,23 @@ export async function preflightPlugin(pluginDir, opts = {}) {
|
|
|
117
117
|
if (!tool.description || tool.description.length < 8) {
|
|
118
118
|
warnings.push(`Tool "${t}": description is missing or very short (<8 chars) — the model uses this to decide when to call it`);
|
|
119
119
|
}
|
|
120
|
-
if (!tool.
|
|
120
|
+
if (!tool.tool) { errors.push(`Tool "${t}": missing tool module path (tool: ./tools/<name>.mjs)`); continue; }
|
|
121
121
|
|
|
122
|
-
const
|
|
122
|
+
const toolModulePath = path.resolve(pluginDir, tool.tool);
|
|
123
123
|
// Traversal guard
|
|
124
|
-
const inside =
|
|
125
|
-
if (!inside) errors.push(`Tool "${t}":
|
|
126
|
-
else if (!fs.existsSync(
|
|
124
|
+
const inside = toolModulePath === pluginDir || toolModulePath.startsWith(pluginDir + path.sep);
|
|
125
|
+
if (!inside) errors.push(`Tool "${t}": tool module path escapes the plugin directory`);
|
|
126
|
+
else if (!fs.existsSync(toolModulePath)) errors.push(`Tool "${t}": tool module not found: ${tool.tool}`);
|
|
127
127
|
else {
|
|
128
128
|
try {
|
|
129
129
|
// Cache-bust because a previous install may have imported an older
|
|
130
130
|
// copy at the same path in this process.
|
|
131
|
-
const mod = await import(`${pathToFileURL(
|
|
131
|
+
const mod = await import(`${pathToFileURL(toolModulePath).href}?preflight=${Date.now()}`);
|
|
132
132
|
if (typeof mod.call !== 'function') {
|
|
133
|
-
errors.push(`Tool "${t}":
|
|
133
|
+
errors.push(`Tool "${t}": tool module ${tool.tool} does not export an async \`call\` function`);
|
|
134
134
|
}
|
|
135
135
|
} catch (err) {
|
|
136
|
-
errors.push(`Tool "${t}":
|
|
136
|
+
errors.push(`Tool "${t}": tool module ${tool.tool} failed to import: ${err.message}`);
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
package/src/terminal/agents.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const TOOL_ALIASES = new Map([
|
|
|
113
113
|
['grep', 'search_code'],
|
|
114
114
|
]);
|
|
115
115
|
|
|
116
|
-
function canonicalToolName(value) {
|
|
116
|
+
export function canonicalToolName(value) {
|
|
117
117
|
const key = String(value || '').trim().toLowerCase();
|
|
118
118
|
return TOOL_ALIASES.get(key) || key;
|
|
119
119
|
}
|
|
@@ -133,7 +133,7 @@ function normalizeScopedArgs(toolName, args = {}, { projectRoot = null } = {}) {
|
|
|
133
133
|
return next;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
136
|
+
export function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } = {}) {
|
|
137
137
|
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
138
138
|
const allowed = new Set(tools.map(canonicalToolName).filter(Boolean));
|
|
139
139
|
if (!allowed.size) return baseExecutor;
|
|
@@ -152,7 +152,11 @@ function createScopedToolExecutor(baseExecutor, agent, { projectRoot = null } =
|
|
|
152
152
|
baseExecutor,
|
|
153
153
|
toolName,
|
|
154
154
|
normalizeScopedArgs(toolName, args, { projectRoot }),
|
|
155
|
-
|
|
155
|
+
{
|
|
156
|
+
...options,
|
|
157
|
+
internal: true,
|
|
158
|
+
subAgent: agent.slug || agent.command || agent.name || true,
|
|
159
|
+
},
|
|
156
160
|
);
|
|
157
161
|
},
|
|
158
162
|
};
|
|
@@ -317,6 +321,7 @@ export async function runAgentDefinition(agentDefinition, instruction, ctx, sess
|
|
|
317
321
|
token: creds.token,
|
|
318
322
|
toolExecutor,
|
|
319
323
|
approvalManager: agentApproval,
|
|
324
|
+
pluginRegistry: options.pluginRegistry || ctx.pluginRegistry || null,
|
|
320
325
|
});
|
|
321
326
|
|
|
322
327
|
session.turns++;
|
package/src/terminal/main.mjs
CHANGED
|
@@ -306,6 +306,8 @@ async function main() {
|
|
|
306
306
|
\x1b[1mUsage:\x1b[0m
|
|
307
307
|
bahulam Start interactive REPL
|
|
308
308
|
bahulam "instruction" Run a single instruction
|
|
309
|
+
bahulam --agent <slug> -p "x" Run a named agent (local deterministic graph)
|
|
310
|
+
bahulam --workflow <name> -p Run a named workflow (local deterministic graph)
|
|
309
311
|
bahulam --headless -p "x" Non-interactive: auto-approve, JSONL output
|
|
310
312
|
bahulam --headless -p "x" --vision screenshot.png
|
|
311
313
|
Attach an image via the vision analysis pipeline
|
|
@@ -360,7 +362,7 @@ async function main() {
|
|
|
360
362
|
/architect <query> Spawn architecture planning agent
|
|
361
363
|
/agents create <name> Create project-local user-defined agent YAML
|
|
362
364
|
/agents edit <name> Open a local agent YAML in your editor
|
|
363
|
-
/agents sync [name]
|
|
365
|
+
/agents sync [name] Optionally publish local agents to backend/account
|
|
364
366
|
/attach <image-path> Attach an image to next prompt
|
|
365
367
|
/attach clipboard Attach image copied to macOS/Windows clipboard
|
|
366
368
|
/exit Exit the REPL
|
|
@@ -480,7 +482,9 @@ async function main() {
|
|
|
480
482
|
const daemonSpawned = process.env.BAHULAM_DAEMON_SPAWNED === '1';
|
|
481
483
|
const daemonPrompt = daemonSpawned ? (process.env.BAHULAM_DAEMON_INITIAL_PROMPT || '').trim() : '';
|
|
482
484
|
const effectivePrompt = args.prompt || (daemonSpawned && daemonPrompt) || '';
|
|
483
|
-
|
|
485
|
+
const hasGraphTarget = Boolean(args.agent || args.workflow);
|
|
486
|
+
if ((effectivePrompt || hasGraphTarget)
|
|
487
|
+
&& (daemonSpawned || process.argv.includes('--headless') || !process.stdin.isTTY || hasGraphTarget)) {
|
|
484
488
|
const { runHeadless } = await import('../core/headless.mjs');
|
|
485
489
|
await runHeadless({
|
|
486
490
|
instruction: effectivePrompt,
|
|
@@ -490,6 +494,8 @@ async function main() {
|
|
|
490
494
|
cacheReport: args.cacheReport,
|
|
491
495
|
local: args.local,
|
|
492
496
|
vision: args.vision,
|
|
497
|
+
agent: args.agent,
|
|
498
|
+
workflow: args.workflow,
|
|
493
499
|
});
|
|
494
500
|
return;
|
|
495
501
|
}
|