Skip to content
Merged
22 changes: 10 additions & 12 deletions examples/real-autoresearch/grove.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,23 @@ metrics:
unit: GB
description: Peak VRAM usage during training
outcome_policy:
auto_evaluate: true
accept_if:
metric: val_bpb
condition: improved_over_parent
auto_accept:
metric_improves: val_bpb
stop_conditions:
no_improvement_rounds: 5
max_rounds: 20
max_rounds_without_improvement: 5
target_metric:
metric: val_bpb
threshold: 0.85
wall_clock_budget: "3h"
enforcement:
claim_policy:
max_concurrent: 3
lease_duration: "10m"
value: 0.85
budget:
max_wall_clock_seconds: 10800
deliberation_limit:
max_rounds: 20
concurrency:
max_active_claims: 3
max_claims_per_agent: 1
max_claims_per_target: 1
execution:
default_lease_seconds: 600
rate_limits:
max_contributions_per_agent_per_hour: 100
max_contributions_per_grove_per_hour: 300
Expand Down
59 changes: 52 additions & 7 deletions src/core/event-bus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,17 @@ describe("TopologyRouter", () => {
bus.close();
});

test("targetsFor returns correct targets", () => {
test("targetsFor returns RoleEdge objects for each outgoing edge", () => {
const bus = new LocalEventBus();
const router = new TopologyRouter(reviewLoopTopology, bus);
expect(router.targetsFor("coder")).toEqual(["reviewer"]);
expect(router.targetsFor("reviewer")).toEqual(["coder"]);
expect(router.targetsFor("coder")).toEqual([{ target: "reviewer", edgeType: "delegates" }]);
expect(router.targetsFor("reviewer")).toEqual([{ target: "coder", edgeType: "feedback" }]);
expect(router.targetsFor("unknown")).toEqual([]);
bus.close();
});

test("duplicate edges are deduplicated", () => {
const duped: AgentTopology = {
test("targetsFor returns multiple RoleEdges when different edge types point to same target", () => {
const multiEdge: AgentTopology = {
structure: "graph",
roles: [
{
Expand All @@ -237,15 +237,60 @@ describe("TopologyRouter", () => {
],
};
const bus = new LocalEventBus();
const router = new TopologyRouter(duped, bus);
const router = new TopologyRouter(multiEdge, bus);
const edges = router.targetsFor("coder");
// Both edges preserved — distinct (target, edgeType) pairs
expect(edges).toHaveLength(2);
expect(edges).toContainEqual({ target: "reviewer", edgeType: "delegates" });
expect(edges).toContainEqual({ target: "reviewer", edgeType: "feeds" });
bus.close();
});

test("route() publishes one event per target even when multiple edge types point to same target", () => {
const multiEdge: AgentTopology = {
structure: "graph",
roles: [
{
name: "coder",
edges: [
{ target: "reviewer", edgeType: "delegates" },
{ target: "reviewer", edgeType: "feeds" },
],
},
{ name: "reviewer" },
],
};
const bus = new LocalEventBus();
const router = new TopologyRouter(multiEdge, bus);
const received: GroveEvent[] = [];
bus.subscribe("reviewer", (e) => received.push(e));

const targets = router.route("coder", {});

// Should only route once to reviewer despite two edges
// route() deduplicates by target: one event despite two distinct edges
expect(targets).toEqual(["reviewer"]);
expect(received).toHaveLength(1);
bus.close();
});

test("targetsFor deduplicates exact (target, edgeType) duplicate pairs", () => {
const exactDupes: AgentTopology = {
structure: "graph",
roles: [
{
name: "coder",
edges: [
{ target: "reviewer", edgeType: "delegates" },
{ target: "reviewer", edgeType: "delegates" }, // exact duplicate
],
},
{ name: "reviewer" },
],
};
const bus = new LocalEventBus();
const router = new TopologyRouter(exactDupes, bus);
// Exact (target, edgeType) duplicate is collapsed to one entry
expect(router.targetsFor("coder")).toEqual([{ target: "reviewer", edgeType: "delegates" }]);
bus.close();
});
});
56 changes: 56 additions & 0 deletions src/core/examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* CI guard: parse all examples/\*\*\/grove.md files against parseGroveContract.
*
* This test ensures that example GROVE.md files always use valid field names
* and schema-conformant values. Without this guard, field-name drift goes
* undetected until an agent or user copy-pastes the example and gets a parse
* error from parseGroveContract.
*
* New examples are automatically covered — no manual registration required.
*/

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";

import { parseGroveContract } from "./contract.js";

describe("example grove.md files", () => {
test("all examples/*/grove.md files parse against the canonical contract schema", async () => {
const { Glob } = await import("bun");

// Resolve the repo root relative to this file's compile-time location.
// process.cwd() is the repo root when running `bun test` from the project root.
const repoRoot = process.cwd();
const pattern = "examples/**/grove.md";

const files: string[] = [];
for (const rel of new Glob(pattern).scanSync({ cwd: repoRoot, absolute: false })) {
files.push(join(repoRoot, rel));
}

expect(files.length).toBeGreaterThan(0);

const failures: Array<{ file: string; error: string }> = [];

for (const filePath of files) {
try {
const content = readFileSync(filePath, "utf8");
parseGroveContract(content);
} catch (err) {
failures.push({
file: filePath.replace(`${repoRoot}/`, ""),
error: err instanceof Error ? err.message : String(err),
});
}
}

if (failures.length > 0) {
const report = failures.map((f) => ` ${f.file}:\n ${f.error}`).join("\n");
throw new Error(
`${failures.length} example grove.md file(s) failed to parse:\n${report}\n\n` +
"Fix the field names to match the canonical contract schema in src/core/contract.ts",
);
}
});
});
2 changes: 2 additions & 0 deletions src/core/operations/contribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,7 @@ describe("writeSerial: best-effort handoff failure paths", () => {
});

test("emits console.warn when handoffStore.createMany throws", async () => {
// biome-ignore lint/suspicious/noEmptyBlockStatements: spy suppresses output intentionally
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});

const faultyHandoffStore: OperationDeps["handoffStore"] = {
Expand Down Expand Up @@ -978,6 +979,7 @@ describe("writeSerial: best-effort handoff failure paths", () => {
// Promise, the throw must still be caught — otherwise the already-committed
// contribution would bubble out as an operation error and the idempotency
// slot would be released, allowing duplicate contributions on retry.
// biome-ignore lint/suspicious/noEmptyBlockStatements: spy suppresses output intentionally
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});

// Non-async function so the throw happens synchronously, before any
Expand Down
8 changes: 6 additions & 2 deletions src/core/operations/contribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,8 +851,12 @@ export async function contributeOperation(
`[grove] Warning: topology router is active but agent '${contribution.agent.agentId}' has no role — routing skipped. Set agent.role to enable topology routing.\n`,
);
} else {
const targets = deps.topologyRouter.targetsFor(contribution.agent.role);
if (targets.length > 0) routedTo = [...targets];
const edges = deps.topologyRouter.targetsFor(contribution.agent.role);
// Deduplicate by target: a role may have multiple edge types (e.g.
// delegates + feeds) pointing at the same downstream role. Creating
// one handoff per (source, target) pair is correct; creating one per
// edge type would produce duplicate pending handoffs for the same work.
if (edges.length > 0) routedTo = [...new Set(edges.map((e) => e.target))];
}
}

Expand Down
Loading
Loading