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
50 changes: 47 additions & 3 deletions .agent.example/agent.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,34 @@
"intervalMin": 30
},

"_comment_session": "idleCompactMin: idle sessions over the threshold are compacted by the heartbeat sweep (0 = off). hardLimitPct: size-triggered compaction may interrupt a running task only above this % of contextBudgetChars.",
"session": {
"compactionThreshold": 60,
"recentKeep": 20
"recentKeep": 20,
"idleCompactMin": 30,
"hardLimitPct": 90
},

"_comment_memory": "limits: per-file character caps — oldest content is truncated from the top when exceeded. review: background self-improvement pass — every everyTurns turns, durable facts from the conversation are promoted into MEMORY.md via the auxiliary LLM. Set review.enabled false or everyTurns 0 to disable.",
"_comment_contextBudget": "Character budget for the always-on system prompt, per section. /context shows the actual split; start-up warns when a section overflows.",
"contextBudget": {
"total": 48000,
"sections": { "soul": 12000, "tooling": 6000, "skillsCatalog": 3000, "memory": 2200, "dailyNotes": 8000, "alwaysSkills": 12000, "instincts": 2500 }
},

"_comment_skills": "selection: model (agent calls skill_load when a description fits) | keyword (legacy word overlap, fallback for one release). retainTurns: turns a loaded skill stays after its last use.",
"skills": {
"selection": "model",
"retainTurns": 3
},

"_comment_guard": "Guardrails around tool calls. Rules are markdown files in rules/*.md; protected platform rules ship with the runtime. record rules append to recordFile, rotated at recordMaxBytes; observers slower than observerTimeoutMs are abandoned.",
"guard": {
"observerTimeoutMs": 2000,
"recordFile": "data/toolcalls.jsonl",
"recordMaxBytes": 10485760
},

"_comment_memory": "limits: per-file character caps — oldest content is truncated from the top when exceeded (the ## Rules heading of SOUL.md is exempt). review: background self-improvement pass every everyTurns turns via the auxiliary LLM. instincts: learned behaviours with confidence — bands decide what a behaviour may do, reinforce/correct/decay move it, pinned ones never decay.",
"memory": {
"limits": {
"soul": 12000,
Expand All @@ -32,9 +54,25 @@
"review": {
"enabled": true,
"everyTurns": 10
},
"instincts": {
"enabled": true,
"bands": { "suggested": 0.3, "apply": 0.5, "core": 0.8 },
"start": { "correction": 0.6, "errorResolution": 0.5, "repetition": 0.5, "explicitRule": 0.9 },
"reinforce": 0.05,
"correct": 0.2,
"decay": 0.05,
"decayDays": 14,
"maxInjected": 8
}
},

"_comment_verification": "After a mutating tool ran, the draft reply is checked against the tool outcomes by the auxiliary LLM before delivery; past timeoutMs the draft goes out marked unverified.",
"verification": {
"enabled": true,
"timeoutMs": 8000
},

"_comment_s3": "Sync is event-driven (fs.watch + diff). syncIntervalSec is an optional periodic full-sweep safety net — set 0 to disable, or e.g. 21600 (6h) for paranoid backup.",
"s3": {
"syncIntervalSec": 0,
Expand Down Expand Up @@ -71,6 +109,12 @@
"webFetch": {
"maxChars": 8000
},
"maxOutputChars": 16000
"maxOutputChars": 16000,
"coreCatalogue": ["exec", "file", "http", "web_fetch", "memory_search", "secret_get", "skill_load", "tool_describe"],
"delegate": {
"timeoutMs": 300000,
"maxParallel": 5,
"summaryChars": 4000
}
}
}
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,46 @@ All runtime settings live in `.agent/agent.config.json`. You only need to specif
// Compact session history after this many events
"compactionThreshold": 60,
// Keep this many recent events after compaction
"recentKeep": 20
"recentKeep": 20,
// Idle sessions over the threshold are compacted by the heartbeat sweep (minutes, 0 = off)
"idleCompactMin": 30,
// Size-triggered compaction may interrupt a running task only above this % of contextBudgetChars
"hardLimitPct": 90
},

// Character budget for the always-on system prompt. `/context` shows the actual split.
"contextBudget": {
"total": 48000,
"sections": { "soul": 12000, "tooling": 6000, "skillsCatalog": 3000, "memory": 2200, "dailyNotes": 8000, "alwaysSkills": 12000, "instincts": 2500 }
},

"skills": {
// "model": the agent pulls a skill with `skill_load` when its description fits; "keyword": legacy word overlap
"selection": "model",
// Turns a loaded skill stays in the conversation after its last use
"retainTurns": 3
},

// Guardrails around tool calls — rules live in .agent/rules/*.md, see "Guardrails" below
"guard": {
"observerTimeoutMs": 2000,
"recordFile": "data/toolcalls.jsonl",
"recordMaxBytes": 10485760
},

// Learned behaviours with confidence — see "Learning" below
"memory": {
"instincts": {
"enabled": true,
"bands": { "suggested": 0.3, "apply": 0.5, "core": 0.8 },
"decayDays": 14,
"maxInjected": 8
}
},

// After a mutating tool ran, the reply is checked against tool outcomes before delivery
"verification": { "enabled": true, "timeoutMs": 8000 },

"s3": {
// Auto-sync interval for S3 backup (seconds)
"syncIntervalSec": 60
Expand All @@ -123,7 +160,11 @@ All runtime settings live in `.agent/agent.config.json`. You only need to specif
"webFetch": {
// Max characters returned from web_fetch tool
"maxChars": 8000
}
},
// Tools with a full description always in the prompt; the rest are one-line stubs + `tool_describe`
"coreCatalogue": ["exec", "file", "http", "web_fetch", "memory_search", "secret_get", "skill_load", "tool_describe"],
// delegate_task worker budgets
"delegate": { "timeoutMs": 300000, "maxParallel": 5, "summaryChars": 4000 }
}
}
```
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"cli": "bun run src/cli.ts",
"build": "bun build src/index.ts --outdir dist --target bun && bun build src/cli.ts --outfile dist/cli.js --target bun",
"test": "bun test",
"monitor": "bash scripts/monitor.sh"
"monitor": "bash scripts/monitor.sh",
"eval": "bun run scripts/eval.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "0.79.0",
Expand Down
63 changes: 63 additions & 0 deletions scripts/eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bun
// Evaluation runner for the harness principles (CLEAN-150).
//
// Reads every `tests/evals/*.jsonl` (or the files given as arguments), runs
// each case through the assertion mode named in the file header, and prints
// one table. Exits non-zero when a set misses the threshold its header
// declares. The modes land with their user stories; until then a set whose
// mode is unknown is reported as "pending" and does not fail the run.
import { readdirSync, readFileSync } from "fs"
import { join, basename } from "path"

interface IEvalHeader { mode: string; threshold: number; description?: string }
interface IEvalCase { id: string; [key: string]: unknown }
interface IEvalSet { file: string; header: IEvalHeader; cases: IEvalCase[] }

type Mode = (cases: IEvalCase[]) => Promise<{ passed: number; failed: string[] }>

/** Assertion modes, filled in per story (US1 blocked, US2 skill-loaded, …). */
const MODES: Record<string, Mode> = {}

function loadSet(file: string): IEvalSet {
const lines = readFileSync(file, "utf8").split("\n").filter(l => l.trim())
const header = JSON.parse(lines[0] ?? "{}") as IEvalHeader
const cases = lines.slice(1).map(l => JSON.parse(l) as IEvalCase)
return { file, header, cases }
}

function discover(args: string[]): string[] {
if (args.length > 0) return args
const dir = join(import.meta.dir, "..", "tests", "evals")
try {
return readdirSync(dir).filter(f => f.endsWith(".jsonl")).map(f => join(dir, f))
} catch {
return []
}
}

async function main(): Promise<void> {
const sets = discover(process.argv.slice(2)).map(loadSet)
if (sets.length === 0) {
console.log("no eval sets under tests/evals/")
return
}
let failures = 0
const rows: string[][] = [["set", "mode", "cases", "pass", "rate", "threshold", "status"]]
for (const set of sets) {
const mode = MODES[set.header.mode]
if (!mode) {
rows.push([basename(set.file), set.header.mode, String(set.cases.length), "-", "-", `${set.header.threshold}%`, "pending"])
continue
}
const result = await mode(set.cases)
const rate = set.cases.length === 0 ? 0 : Math.round((result.passed / set.cases.length) * 100)
const ok = rate >= set.header.threshold
if (!ok) failures++
rows.push([basename(set.file), set.header.mode, String(set.cases.length), String(result.passed), `${rate}%`, `${set.header.threshold}%`, ok ? "ok" : `FAIL (${result.failed.slice(0, 3).join(", ")})`])
}
const widths = rows[0]!.map((_, i) => Math.max(...rows.map(r => r[i]!.length)))
for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i]!)).join(" "))
if (failures > 0) process.exit(1)
}

await main()
24 changes: 12 additions & 12 deletions specs/001-ecc-agent-principles/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ CleanSlice vertical slices under `src/slices/{setup,agent,bot,runtime}/<slice>/{

**Purpose**: Config surface and shared type changes that carry no behaviour yet.

- [ ] T001 Add `contextBudget`, `skills`, `tools.coreCatalogue`, `tools.delegate`, `guard`, `memory.instincts`, `session.idleCompactMin`, `session.hardLimitPct`, `verification` to `IAgentConfig` with the defaults from contracts/commands-and-config.md in `src/slices/runtime/init/domain/init.types.ts`
- [ ] T002 Merge the new config sections with defaults in the config loader so a partial `agent.config.json` still validates, in `src/slices/runtime/init/domain/init.service.ts`; add a spec case for a config that sets only `memory.instincts.decayDays` in `src/slices/runtime/init/domain/init.service.spec.ts`
- [ ] T003 [P] Add `effect?: "read" | "write"` to `Tool` with the docstring "unset means write" in `src/slices/agent/tool/domain/tool.types.ts`
- [ ] T004 [P] Document the new `agent.config.json` sections in the "Agent configuration" block of `README.md` and in `.agent.example/agent.config.json`
- [ ] T005 [P] Add `"eval": "bun run scripts/eval.ts"` to `package.json` scripts and create the empty runner skeleton `scripts/eval.ts` that reads `tests/evals/*.jsonl` and prints a table (no assertions yet)
- [X] T001 Add `contextBudget`, `skills`, `tools.coreCatalogue`, `tools.delegate`, `guard`, `memory.instincts`, `session.idleCompactMin`, `session.hardLimitPct`, `verification` to `IAgentConfig` with the defaults from contracts/commands-and-config.md in `src/slices/runtime/init/domain/init.types.ts`
- [X] T002 Merge the new config sections with defaults in the config loader so a partial `agent.config.json` still validates, in `src/slices/runtime/init/domain/init.service.ts`; add a spec case for a config that sets only `memory.instincts.decayDays` in `src/slices/runtime/init/domain/init.service.spec.ts`
- [X] T003 [P] Add `effect?: "read" | "write"` to `Tool` with the docstring "unset means write" in `src/slices/agent/tool/domain/tool.types.ts`
- [X] T004 [P] Document the new `agent.config.json` sections in the "Agent configuration" block of `README.md` and in `.agent.example/agent.config.json`
- [X] T005 [P] Add `"eval": "bun run scripts/eval.ts"` to `package.json` scripts and create the empty runner skeleton `scripts/eval.ts` that reads `tests/evals/*.jsonl` and prints a table (no assertions yet)

---

Expand All @@ -43,13 +43,13 @@ CleanSlice vertical slices under `src/slices/{setup,agent,bot,runtime}/<slice>/{

**⚠️ CRITICAL**: No user story work can begin until this phase is complete.

- [ ] T006 Annotate every built-in tool with `effect` per the read/write table in contracts/tools.md across `src/slices/agent/tool/data/repositories/**/*.repository.ts` (`file` and `http` decide per call: add `effectOf?(params)` to `Tool` in `src/slices/agent/tool/domain/tool.types.ts` and implement it for those two)
- [ ] T007 Map MCP `readOnlyHint` annotations to `effect: "read"` and default the rest to `"write"` where MCP tools are built in `src/slices/setup/mcp/data/mcp.gateway.ts`
- [ ] T008 Write a spec asserting every tool registered by `ToolGateway` declares `effect` (or `effectOf`) in `src/slices/agent/tool/data/tool.gateway.spec.ts`
- [ ] T009 Change `AgentService.buildSystemPrompt` to return `{ prompt, breakdown: IPromptBreakdown }` with per-section char counts (soul, channel, tooling, agents, user, memory, dailyNotes, heartbeat, skillsCatalog, credentials, runtime) in `src/slices/agent/agent/domain/agent.service.ts`; add `IPromptBreakdown` to `src/slices/agent/agent/domain/agent.types.ts`; update the two callers in `src/slices/agent/agent/agent.module.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [ ] T010 Spec for the breakdown: section sums equal prompt length, sections absent for non-admin are reported as 0, in `src/slices/agent/agent/domain/agent.service.spec.ts`
- [ ] T011 Keep the last `IPromptBreakdown` per session in `RuntimeService` (`lastBreakdown: Map<sessionId, IPromptBreakdown>`) and expose `getLastBreakdown(sessionId)` in `src/slices/runtime/runtime/domain/runtime.service.ts`
- [ ] T012 Add `inFlight(sessionId): boolean`, `parentTaskId?` on `ITask`, and `getChildren(parentTaskId)` to the task manager in `src/slices/agent/task/domain/task.types.ts`, `src/slices/agent/task/domain/task.service.ts`, `src/slices/agent/task/data/task.gateway.ts`; spec in `src/slices/agent/task/domain/task.service.spec.ts`
- [X] T006 Annotate every built-in tool with `effect` per the read/write table in contracts/tools.md across `src/slices/agent/tool/data/repositories/**/*.repository.ts` (`file` and `http` decide per call: add `effectOf?(params)` to `Tool` in `src/slices/agent/tool/domain/tool.types.ts` and implement it for those two)
- [X] T007 Map MCP `readOnlyHint` annotations to `effect: "read"` and default the rest to `"write"` where MCP tools are built in `src/slices/setup/mcp/data/mcp.gateway.ts`
- [X] T008 Write a spec asserting every tool registered by `ToolGateway` declares `effect` (or `effectOf`) in `src/slices/agent/tool/data/tool.gateway.spec.ts`
- [X] T009 Change `AgentService.buildSystemPrompt` to return `{ prompt, breakdown: IPromptBreakdown }` with per-section char counts (soul, channel, tooling, agents, user, memory, dailyNotes, heartbeat, skillsCatalog, credentials, runtime) in `src/slices/agent/agent/domain/agent.service.ts`; add `IPromptBreakdown` to `src/slices/agent/agent/domain/agent.types.ts`; update the two callers in `src/slices/agent/agent/agent.module.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [X] T010 Spec for the breakdown: section sums equal prompt length, sections absent for non-admin are reported as 0, in `src/slices/agent/agent/domain/agent.service.spec.ts`
- [X] T011 Keep the last `IPromptBreakdown` per session in `RuntimeService` (`lastBreakdown: Map<sessionId, IPromptBreakdown>`) and expose `getLastBreakdown(sessionId)` in `src/slices/runtime/runtime/domain/runtime.service.ts`
- [X] T012 Add `inFlight(sessionId): boolean`, `parentTaskId?` on `ITask`, and `getChildren(parentTaskId)` to the task manager in `src/slices/agent/task/domain/task.types.ts`, `src/slices/agent/task/domain/task.service.ts`, `src/slices/agent/task/data/task.gateway.ts`; spec in `src/slices/agent/task/domain/task.service.spec.ts`

**Checkpoint**: `bun test` green; no user-visible behaviour change.

Expand Down
6 changes: 5 additions & 1 deletion src/slices/agent/agent/agent.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { AgentGateway } from "./data/agent.gateway"
import { AgentService } from "./domain/agent.service"
import type { SkillSummary } from "../skill/domain/skill.types"
import type { IBuiltPrompt } from "./domain/agent.types"

export type { IBuiltPrompt, IPromptBreakdown, IPromptSection, IOnDemandLoad, PromptSectionName } from "./domain/agent.types"
export { SECTION_SEPARATOR } from "./domain/agent.service"

export class AgentModule {
private service: AgentService
Expand All @@ -9,7 +13,7 @@ export class AgentModule {
this.service = new AgentService(new AgentGateway())
}

async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string; citationsPrompt?: string }): Promise<string> {
async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string; citationsPrompt?: string }): Promise<IBuiltPrompt> {
return this.service.buildPrompt(this.agentDir, opts)
}
}
49 changes: 49 additions & 0 deletions src/slices/agent/agent/domain/agent.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { AgentService, SECTION_SEPARATOR } from "./agent.service"
import type { IAgentGateway } from "./agent.gateway"
import type { AgentConfig } from "./agent.types"

const gateway: IAgentGateway = {
load: async () => ({ skills: [] }),
loadUserContext: async () => undefined,
} as unknown as IAgentGateway

const config: AgentConfig = {
soul: "Be brief.\n\n<!-- admin-only -->Secret admin paragraph.<!-- /admin-only -->",
user: "Works nights.",
memory: "Likes tables.",
skills: [{ name: "ranch", description: "Operate the ranch API" }],
}

describe("buildSystemPrompt breakdown (CLEAN-150 US2)", () => {
test("section chars plus separators add up to the prompt length", () => {
const built = new AgentService(gateway).buildSystemPrompt(config, {
isAdmin: true,
toolingPrompt: "## Tooling\n- exec",
secretKeys: ["GITHUB_TOKEN"],
agentDir: "/agent",
})
const chars = built.sections.reduce((n, s) => n + s.chars, 0)
const separators = (built.sections.length - 1) * SECTION_SEPARATOR.length
expect(chars + separators).toBe(built.prompt.length)
})

test("every section is named and appears in emission order", () => {
const built = new AgentService(gateway).buildSystemPrompt(config, { isAdmin: true, toolingPrompt: "## Tooling" })
expect(built.sections.map(s => s.name)).toEqual(["soul", "tooling", "user", "memory", "skillsCatalog", "silent"])
})

test("admin-only content is stripped for non-admins and the soul section shrinks accordingly", () => {
const service = new AgentService(gateway)
const admin = service.buildSystemPrompt(config, { isAdmin: true })
const visitor = service.buildSystemPrompt(config, { isAdmin: false })
const soulOf = (b: typeof admin) => b.sections.find(s => s.name === "soul")!.chars
expect(soulOf(visitor)).toBeLessThan(soulOf(admin))
expect(visitor.prompt).not.toContain("Secret admin paragraph")
})

test("optional sections are absent, not zero-width entries", () => {
const built = new AgentService(gateway).buildSystemPrompt({ skills: [] }, {})
expect(built.sections.map(s => s.name)).toEqual(["silent"])
})
})
Loading