@bahulam/code 0.1.11 → 0.1.13
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/commands/install.mjs +295 -0
- package/src/commands/plugin-manage.mjs +280 -88
- package/src/config/cli-args.mjs +16 -0
- package/src/config/settings-loader.mjs +15 -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 +266 -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 +30 -27
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/scaffold.mjs +487 -0
- package/src/plugins/pi-compat/shim.mjs +134 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +35 -10
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +39 -7
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +624 -103
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +107 -4
- package/src/ui/input-dock.mjs +5 -2
- 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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import { load as yamlLoad } from 'js-yaml';
|
|
10
|
+
import { normalizeComposes } from './pi-compose.mjs';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Parse a YAML text string into an object using js-yaml.
|
|
@@ -26,40 +27,40 @@ function normalizeToolNames(value) {
|
|
|
26
27
|
}).filter(Boolean);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
30
|
+
function loadAgentFile(agentDef, pluginDir) {
|
|
31
|
+
const file = String(agentDef.file || agentDef.handler || '').trim();
|
|
32
|
+
if (!file || !pluginDir) return {};
|
|
32
33
|
try {
|
|
33
|
-
const
|
|
34
|
-
const raw = fs.readFileSync(
|
|
35
|
-
return path.extname(
|
|
34
|
+
const filePath = path.resolve(pluginDir, file);
|
|
35
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
36
|
+
return path.extname(filePath).toLowerCase() === '.json'
|
|
36
37
|
? JSON.parse(raw)
|
|
37
38
|
: parseYaml(raw);
|
|
38
39
|
} catch (err) {
|
|
39
40
|
if (process.env.DEBUG) {
|
|
40
|
-
console.error(`Failed to load plugin agent
|
|
41
|
+
console.error(`Failed to load plugin agent file ${file}: ${err.message}`);
|
|
41
42
|
}
|
|
42
43
|
return {};
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
function normalizeAgentDef(agentDef, pluginName, pluginDir) {
|
|
47
|
-
const
|
|
48
|
-
const metadata =
|
|
49
|
-
const agent =
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
||
|
|
48
|
+
const fileConfig = loadAgentFile(agentDef, pluginDir);
|
|
49
|
+
const metadata = fileConfig.metadata || fileConfig.meta || {};
|
|
50
|
+
const agent = fileConfig.agent || fileConfig.spec?.agent || {};
|
|
51
|
+
const fileTools = (
|
|
52
|
+
fileConfig.tools
|
|
53
|
+
|| fileConfig.spec?.tools
|
|
53
54
|
|| agent.tools
|
|
54
55
|
|| []
|
|
55
56
|
);
|
|
56
57
|
const inlineTools = normalizeToolNames(agentDef.tools);
|
|
57
58
|
|
|
58
59
|
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 ||
|
|
60
|
+
slug: agentDef.slug || metadata.slug || fileConfig.slug || metadata.name || fileConfig.name || agentDef.name || '',
|
|
61
|
+
name: agentDef.name || metadata.name || fileConfig.name || agentDef.slug || '',
|
|
62
|
+
description: agentDef.description || metadata.description || fileConfig.description || '',
|
|
63
|
+
role: agentDef.role || metadata.role || fileConfig.role || 'specialist',
|
|
63
64
|
system_prompt: (
|
|
64
65
|
agentDef.system_prompt
|
|
65
66
|
|| agentDef.systemPrompt
|
|
@@ -67,16 +68,16 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
|
|
|
67
68
|
|| agent.system_prompt
|
|
68
69
|
|| agent.systemPrompt
|
|
69
70
|
|| agent.prompt
|
|
70
|
-
||
|
|
71
|
-
||
|
|
71
|
+
|| fileConfig.system_prompt
|
|
72
|
+
|| fileConfig.prompt
|
|
72
73
|
|| ''
|
|
73
74
|
),
|
|
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
|
-
|
|
75
|
+
tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
|
|
76
|
+
model: agentDef.model || agent.model || fileConfig.model || null,
|
|
77
|
+
models: agentDef.models || agent.models || fileConfig.models || undefined,
|
|
78
|
+
max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
|
|
79
|
+
max_iterations: agentDef.max_iterations || agent.max_iterations || fileConfig.max_iterations || undefined,
|
|
80
|
+
file: agentDef.file || agentDef.handler || '',
|
|
80
81
|
source: `plugin:${pluginName}`,
|
|
81
82
|
source_scope: 'plugin',
|
|
82
83
|
};
|
|
@@ -164,7 +165,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
164
165
|
name: toolDef.name || '',
|
|
165
166
|
description: toolDef.description || '',
|
|
166
167
|
input_schema: toolDef.parameters || toolDef.input_schema || toolDef.inputSchema || { type: 'object', properties: {} },
|
|
167
|
-
|
|
168
|
+
tool: toolDef.tool || toolDef.file || toolDef.handler || '',
|
|
168
169
|
plugin_name: name,
|
|
169
170
|
};
|
|
170
171
|
if (tool.name) tools.push(tool);
|
|
@@ -185,6 +186,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
185
186
|
// Inline wins on name collision so authors can override a portable
|
|
186
187
|
// config for the local plugin without editing mcp.json.
|
|
187
188
|
const mcpServers = _readMcpServers(spec.mcpServers, source);
|
|
189
|
+
const composes = normalizeComposes(spec.composes);
|
|
188
190
|
|
|
189
191
|
return {
|
|
190
192
|
apiVersion,
|
|
@@ -201,6 +203,7 @@ export function normalizeManifest(raw, source = '') {
|
|
|
201
203
|
agents,
|
|
202
204
|
workspace,
|
|
203
205
|
mcpServers,
|
|
206
|
+
composes,
|
|
204
207
|
},
|
|
205
208
|
source,
|
|
206
209
|
_dir: source ? path.dirname(source) : '',
|
|
@@ -277,7 +280,7 @@ export function validatePluginManifest(manifest) {
|
|
|
277
280
|
if (manifest.spec) {
|
|
278
281
|
for (const tool of (manifest.spec.tools || [])) {
|
|
279
282
|
if (!tool.name) errors.push('Tool missing name');
|
|
280
|
-
if (!tool.
|
|
283
|
+
if (!tool.tool) errors.push(`Tool "${tool.name || '(unnamed)'}" missing tool module path (tool: ./tools/<name>.mjs)`);
|
|
281
284
|
}
|
|
282
285
|
for (const agent of (manifest.spec.agents || [])) {
|
|
283
286
|
if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node ESM loader hook that intercepts `import { pi } from 'pi'` and
|
|
3
|
+
* resolves it to a virtual module which re-exports our shim.
|
|
4
|
+
*
|
|
5
|
+
* Registered from probe.mjs via child_process spawn with:
|
|
6
|
+
* node --import ./loader-hook.mjs -e '<probe script>'
|
|
7
|
+
*
|
|
8
|
+
* The virtual module source is generated at load time so it can inline
|
|
9
|
+
* the shim import URL (avoids brittle relative paths across cwd's).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { register } from 'node:module';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const SHIM_URL = pathToFileURL(path.join(HERE, 'shim.mjs')).href;
|
|
19
|
+
const VIRTUAL_URL = 'bahulam-pi-shim:v1';
|
|
20
|
+
|
|
21
|
+
// Register ourselves as a loader — this file is `--import`ed, and the
|
|
22
|
+
// register() call installs the resolve/load hooks below into a worker
|
|
23
|
+
// data URL. Simpler than a standalone hooks file.
|
|
24
|
+
register(`data:text/javascript,${encodeURIComponent(`
|
|
25
|
+
export function resolve(specifier, context, nextResolve) {
|
|
26
|
+
if (specifier === 'pi') return { shortCircuit: true, url: '${VIRTUAL_URL}' };
|
|
27
|
+
return nextResolve(specifier, context);
|
|
28
|
+
}
|
|
29
|
+
export function load(url, context, nextLoad) {
|
|
30
|
+
if (url === '${VIRTUAL_URL}') {
|
|
31
|
+
return {
|
|
32
|
+
shortCircuit: true,
|
|
33
|
+
format: 'module',
|
|
34
|
+
source: \`
|
|
35
|
+
import { createPiShim } from ${JSON.stringify(SHIM_URL)};
|
|
36
|
+
const captured = globalThis.__bahulam_pi_captured ||= { tools: [], commands: [] };
|
|
37
|
+
const pluginName = process.env.BAHULAM_PI_PLUGIN || 'pi';
|
|
38
|
+
export const pi = createPiShim({ pluginName, captured });
|
|
39
|
+
export default pi;
|
|
40
|
+
\`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return nextLoad(url, context);
|
|
44
|
+
}
|
|
45
|
+
`)}`, import.meta.url);
|