Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions orchestrator-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,20 @@ const workItemSource: WorkItemSource = {
Runners and the orchestrator authenticate with pre-shared ed25519 keys. Adding a runner requires updating config and restarting the orchestrator.

```typescript
import { runOrchestrator, loadAuthorizedRunners } from "@bifrost-ai/orchestrator";
import { Orchestrator, loadAuthorizedRunners } from "@bifrost-ai/orchestrator";
import { exportPublicKeyPem, generateKeyPair } from "@bifrost-ai/protocol";

const orchestratorIdentity = generateKeyPair("orchestrator");
const runnerIdentity = generateKeyPair("runner-1");

const handle = await runOrchestrator({
const orchestrator = new Orchestrator();
orchestrator.registerWorkItemSource(workItemSource);

const handle = await orchestrator.start({
identity: orchestratorIdentity,
authorizedRunners: loadAuthorizedRunners([
{ keyId: runnerIdentity.keyId, publicKeyPem: exportPublicKeyPem(runnerIdentity.publicKey) },
]),
workItemSource,
scheduler: {
async call(method, params) {
// workflow scheduling callbacks from runners
Expand All @@ -131,19 +133,18 @@ Runners dial the orchestrator over WebSocket. With `runner.yaml` present, keys a

```typescript
import { Runner, createDataRegistry } from "@bifrost-ai/runner";
import { enrollTaskAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task";
import "@bifrost-ai/agent-3-task/augment";
import { loadAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task";
import { ClaudeCodeEngine } from "@bifrost-ai/engine-claude-code";
import { CursorEngine } from "@bifrost-ai/engine-cursor";

const claudeEngine = new ClaudeCodeEngine();
const cursorEngine = new CursorEngine();
const data = createDataRegistry(taskAgentDataGuards);
const runner = new Runner({ data });

data.get("engine").register("claude", claudeEngine);
data.get("engine").register("cursor", cursorEngine);
enrollTaskAgent(runner, reviewerAgent);
runner.registerWorkItemHandler(echo);
runner.registerEngine("claude", new ClaudeCodeEngine());
runner.registerEngine("cursor", new CursorEngine());
runner.registerTaskAgent("reviewer", await loadAgent("./agents/reviewer/AGENT.md"));
runner.registerScriptAgent("echo", echo);

await runner.start();
```
Expand Down
21 changes: 18 additions & 3 deletions orchestrator-v2/docs/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sequenceDiagram

| Module | Responsibility |
| -------------------- | --------------------------------------------------------------- |
| `runOrchestrator` | Main loop: watch tasks, dispatch, drain, cleanup |
| `Orchestrator` | Main loop: watch tasks, apply mappers, dispatch, drain, cleanup |
| `PeerRegistry` | Track connected peers, heartbeats, in-flight counts |
| `DispatchTracker` | Map dispatch IDs and task IDs to in-flight entries |
| `dispatcher` | Send `dispatch` RPC to a peer |
Expand Down Expand Up @@ -79,17 +79,32 @@ The dispatch loop blocks on `waitForAvailablePeer()` until a runner meets all th

### Configuration

Registration happens on the `Orchestrator` instance before `start()`. Runtime options are passed to `start()` and do not include `workItemSource`.

```typescript
type OrchestratorOptions = {
import { Orchestrator, loadAuthorizedRunners } from "@bifrost-ai/orchestrator";

const orchestrator = new Orchestrator();
orchestrator.registerWorkItemSource(workItemSource);
orchestrator.addWorkItemMapper("task", (workItem) => workItem);

type OrchestratorStartOptions = {
identity: PeerIdentity;
authorizedRunners: ReadonlyMap<string, KeyObject>;
workItemSource: WorkItemSource;
scheduler: Scheduler;
host?: string;
port?: number;
heartbeatTimeoutMs?: number; // default 30000
maxInFlightPerPeer?: number; // default 1
abortSignal?: AbortSignal;
};

const handle = await orchestrator.start({
identity: orchestratorIdentity,
authorizedRunners: loadAuthorizedRunners([{ keyId, publicKeyPem }]),
scheduler,
port: 9100,
});
```

Authorized runners are loaded via `loadAuthorizedRunners([{ keyId, publicKeyPem }])`. Adding a runner requires updating this list and restarting.
Expand Down
11 changes: 6 additions & 5 deletions orchestrator-v2/docs/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,21 @@ Scripts and other runnable agents are enrolled incrementally. Create the data re

```typescript
import { Runner, createDataRegistry } from "@bifrost-ai/runner";
import "@bifrost-ai/agent-3-task/augment";
import { echo } from "./scripts/echo.js";
import { enrollTaskAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task";
import { loadAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task";

const data = createDataRegistry(taskAgentDataGuards);
const runner = new Runner({ data });

data.get("engine").register("claude", claudeEngine);
enrollTaskAgent(runner, reviewerAgent);
runner.registerWorkItemHandler(echo);
runner.registerEngine("claude", claudeEngine);
runner.registerTaskAgent("reviewer", await loadAgent("./agents/reviewer/AGENT.md"));
runner.registerScriptAgent("echo", echo);

await runner.start();
```

`createTaskAgent(agent)` couples the agent definition with its handler at registration time. `enrollTaskAgent` also registers the definition in `data.get("agentDefinition")` for lookup by other agents.
`registerTaskAgent(name, agent)` registers the handler under the dispatch name and stores the definition in `data.get("agentDefinition")` for lookup by other agents.

When `runner.yaml` (or `.bifrost-runner.yaml`) is present, keys and orchestrator URL are loaded automatically inside `start()` — no manual key handling required.

Expand Down
9 changes: 6 additions & 3 deletions orchestrator-v2/examples/lvl3/agents/cowsay/AGENT.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
---
name: cowsay
parameters:
phrase: string?
user_prompt: string
description: A cow that says things
tools: []
template:
parameters:
phrase: string?
user_prompt: string
---

BDD Red only implements the tests. The cow says {{phrase}}
Expand Down
9 changes: 6 additions & 3 deletions orchestrator-v2/examples/lvl3/doSomething.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// export const doSomething = createScriptAgent(({ cwd, taskState }) => {
// console.log(`the cwd is ${cwd} for task ${JSON.stringify(taskState)}`);
// });
import type { ScriptFn } from "@bifrost-ai/runner";

export const doSomething: ScriptFn = ({ cwd, workItem }) => {
console.log(`the cwd is ${cwd} for task ${JSON.stringify(workItem.state)}`);
return { outcome: "completed" };
};
36 changes: 17 additions & 19 deletions orchestrator-v2/examples/lvl3/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
// import { Orchestrator } from "@bifrost-ai/orchestrator";
// import { type TaskAgentState } from "@bifrost-ai/agent-3-task";
// import { type RuneDetail } from "@bifrost-ai/work-item-source-bifrost";
import { Orchestrator } from "@bifrost-ai/orchestrator";
import { type TaskAgentState } from "@bifrost-ai/agent-3-task";
import { BifrostWorkItemSource, type RuneDetail } from "@bifrost-ai/work-item-source-bifrost";

// const orchestrator = new Orchestrator();
export const orchestrator = new Orchestrator();

// const bifrost = new BifrostWorkItemSource();
orchestrator.registerWorkItemSource(new BifrostWorkItemSource());

// orchestrator.registerWorkItemSource(bifrost);

// orchestrator.addWorkItemMapper(
// "task",
// (rune: RuneDetail): Promise<TaskAgentState> =>
// Promise.resolve({
// instructions: rune.description,
// workingDir: rune.state.workingDir,
// engineName: rune.state.engineName,
// sessionId: rune.state.sessionId,
// }),
// );

// await orchestrator.run();
orchestrator.addWorkItemMapper("task", (workItem) => {
const rune = workItem.metadata as RuneDetail;
return {
...workItem,
state: {
instructions: rune.description,
workingDir: workItem.state.workingDir as string,
engineName: workItem.state.engineName as string,
sessionId: workItem.state.sessionId as string | undefined,
} satisfies TaskAgentState,
};
});
15 changes: 15 additions & 0 deletions orchestrator-v2/examples/lvl3/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "@bifrost-ai/example-lvl3",
"private": true,
"type": "module",
"dependencies": {
"@bifrost-ai/agent-3-task": "workspace:*",
"@bifrost-ai/engine-cursor": "workspace:*",
"@bifrost-ai/orchestrator": "workspace:*",
"@bifrost-ai/runner": "workspace:*",
"@bifrost-ai/work-item-source-bifrost": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:"
}
}
24 changes: 14 additions & 10 deletions orchestrator-v2/examples/lvl3/runner.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
// import { Runner } from "@bifrost-ai/runner";
// import "@bifrost-ai/agent-3-task/augment";
// import "@bifrost-ai/agent-4-workflow/augment";
// import { doSomething } from "./doSomething";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// const runner = new Runner();
import { Runner, createDataRegistry } from "@bifrost-ai/runner";
import "@bifrost-ai/agent-3-task/augment";
import { loadAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task";
import { CursorEngine } from "@bifrost-ai/engine-cursor";

// runner.registerEngine(new CursorEngine());
// runner.registerTaskAgent("HelloWorld", createTaskAgent("./hello-world.md"));
// runner.registerScriptAgent("doSomething", doSomething);
import { doSomething } from "./doSomething.js";

// runner.registerWorkflowAgent(createWorkflow("trial").step("HelloWorld").step("doSomething"));
const moduleDir = dirname(fileURLToPath(import.meta.url));
const cowsayAgentPath = join(moduleDir, "agents/cowsay/AGENT.md");

// await runner.run();
export const runner = new Runner({ data: createDataRegistry(taskAgentDataGuards) });

runner.registerEngine("cursor", new CursorEngine());
runner.registerTaskAgent("cowsay", await loadAgent(cowsayAgentPath));
runner.registerScriptAgent("doSomething", doSomething);
3 changes: 2 additions & 1 deletion orchestrator-v2/packages/agent-3-task/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"dependencies": {
"@bifrost-ai/engine": "workspace:*",
"@bifrost-ai/interfaces-work": "workspace:*",
"@bifrost-ai/runner": "workspace:*"
"@bifrost-ai/runner": "workspace:*",
"gray-matter": "^4.0.3"
},
"devDependencies": {
"@types/node": "catalog:",
Expand Down
110 changes: 110 additions & 0 deletions orchestrator-v2/packages/agent-3-task/src/agent-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import matter from "gray-matter";
import type { AgentDefinition, AgentTool } from "@bifrost-ai/engine";

const extractHandlebarsTokens = (content: string): Set<string> => {
const tokens = new Set<string>();

const simpleTokenRegex = /\{\{([^#/][^}]*)\}\}/g;
let match: RegExpMatchArray | null = null;
while ((match = simpleTokenRegex.exec(content)) !== null) {
const token = match[1].trim();
const [basePath] = token.split(".")[0].split(" ");
tokens.add(basePath);
}

const blockTokenRegex = /\{\{#(?:if|unless|each)\s+([^}]+)\}\}/g;
while ((match = blockTokenRegex.exec(content)) !== null) {
const token = match[1].trim();
const [basePath] = token.split(".")[0].split(" ");
tokens.add(basePath);
}

return tokens;
};

const getDeclaredParameters = (params: Record<string, unknown>): Set<string> => {
const declared = new Set<string>();

for (const key of Object.keys(params)) {
const baseKey = key.endsWith("?") ? key.slice(0, -1) : key;
declared.add(baseKey);

const value = params[key];
if (typeof value === "object" && value !== null) {
const nestedParams = getDeclaredParameters(value as Record<string, unknown>);
for (const nested of nestedParams) {
declared.add(`${baseKey}.${nested}`);
}
}
}

return declared;
};

export function parseAgentDefinition(content: string): AgentDefinition | null {
try {
const parsed = matter(content);
const data = parsed.data as Record<string, unknown>;
const promptBody = parsed.content;

if (!data.name || typeof data.name !== "string") {
console.error("Missing or invalid required field: name");
return null;
}

if (!data.description || typeof data.description !== "string") {
console.error("Missing or invalid required field: description");
return null;
}

if (!Array.isArray(data.tools)) {
console.error("Missing or invalid required field: tools");
return null;
}

const templateData = data.template as Record<string, unknown> | undefined;
const topLevelParameters = data.parameters as Record<string, unknown> | undefined;
const parameters =
(templateData?.parameters as Record<string, unknown> | undefined) ?? topLevelParameters ?? {};

const usedTokens = extractHandlebarsTokens(promptBody);
const declaredParams = getDeclaredParameters(parameters);
const builtinTokens = new Set(["taskId"]);

for (const token of usedTokens) {
if (!builtinTokens.has(token)) {
let isDeclared = declaredParams.has(token);

if (!isDeclared) {
const parts = token.split(".");
for (let index = parts.length; index > 0; index -= 1) {
const parentPath = parts.slice(0, index).join(".");
if (declaredParams.has(parentPath) || declaredParams.has(`${parentPath}?`)) {
isDeclared = true;
break;
}
}
}

if (!isDeclared) {
console.error(`Undeclared Handlebars token: ${token}`);
return null;
}
}
}

const model = typeof data.model === "string" ? data.model : undefined;

return {
name: data.name,
description: data.description,
tools: data.tools as AgentTool[],
template: { parameters },
promptBody,
model,
};
} catch (error) {
console.error("Failed to parse AGENT.md:", error);
return null;
}
}
Loading