Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,20 @@ Outcomes compose strictest-first (block > redact > record). The platform ships p

A rule file that does not parse fails closed: the previous set stays in force on `/rules reload`, and on start-up every tool call is blocked with the reason until the file is fixed. Observers (`record`) fail open and are bounded by `guard.observerTimeoutMs`. `/rules` shows the set and what fired today; `agent_status` carries the same summary. `bun run eval tests/evals/guardrails.jsonl` checks the baseline against 31 extraction attempts.

### Learning

The agent learns behaviours, not just notes. Every `memory.review.everyTurns` turns (and on `/clear`) the auxiliary model reviews the conversation and reports, in JSON, durable facts for `MEMORY.md` plus **instincts** — one trigger, one action, with evidence pointing at the exchange. Each instinct lives as one markdown file in `.agent/memory/instincts/` and carries a confidence:

| Source | Starts at | Band |
|--------|-----------|------|
| explicit admin rule ("never …", "always …") | 0.9, pinned | core — rendered as a standing rule |
| correction ("shorter next time") | 0.6 | apply — shapes the agent's actions |
| resolved error, repeated workflow | 0.5 | apply |

Confidence moves with experience: +0.05 when the review sees the behaviour applied without complaint, −0.2 when the person pushes back, −0.05 per `decayDays` without reinforcement (pinned ones never decay). Bands (`memory.instincts.bands`): below `suggested` a behaviour is hidden, from `suggested` it is listed for the operator only, from `apply` it is rendered into the prompt, from `core` it reads as a rule. A contradicting pair keeps the higher confidence in the prompt and surfaces the conflict. Learned behaviours can never relax a guardrail — they only ever reach the prompt, never the guard.

`/learned` lists everything by band with evidence counts; `/learned show <id>` the evidence; `pin` / `unpin`, `prune [<id>]`, `promote <id…>` (writes `skills/learned-<slug>/SKILL.md` for review), `export`. `agent_status.learned` carries the counts. `bun run eval tests/evals/corrections.jsonl` checks that one correction holds on the next matching messages.

### Bot commands

Users can interact with the bot via Telegram/Slack commands:
Expand All @@ -405,6 +419,7 @@ Users can interact with the bot via Telegram/Slack commands:
| `/rules` | Guardrails in force and what fired today (admin only) |
| `/rules reload` | Re-read `.agent/rules/` — a bad file keeps the previous set (admin only) |
| `/context` | System prompt size per section vs budget, and what was loaded on demand last turn (admin only) |
| `/learned` | What the agent has learned, with confidence; `show`, `pin`, `unpin`, `prune`, `promote`, `export` (admin only) |

---

Expand Down
51 changes: 49 additions & 2 deletions scripts/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { GuardModule } from "../src/slices/agent/guard"
import { AGENT_CONFIG_DEFAULTS } from "../src/slices/runtime/init/domain/init.types"
import { LlmModule } from "../src/slices/setup/llm/llm.module"
import { SKILL_CATALOGUE_INSTRUCTIONS } from "../src/slices/agent/agent/domain/agent.service"
import { InstinctService, DEFAULT_INSTINCT_CONFIG, type IInstinctGateway } from "../src/slices/agent/instinct"
import type { Tool } from "../src/slices/agent/tool/domain/tool.types"
import type { Event } from "../src/slices/setup/event"

Expand Down Expand Up @@ -84,8 +85,54 @@ const skillLoaded: Mode = async (cases, header) => {
return { passed: cases.length - failed.filter(f => !f.startsWith("false loads")).length, failed }
}

/** Assertion modes, one per story (US3 holds-after-correction and US5 no-false-done land with theirs). */
const MODES: Record<string, Mode> = { blocked, "skill-loaded": skillLoaded }
/**
* US3 — one instinct at its post-correction confidence (apply band) is
* rendered into the prompt; the model's next move on a matching message must
* follow it: the expected tool call (or none), text that matches / avoids a
* regex, or a sentence cap. Needs `LLM_API_KEY`.
*/
const holdsAfterCorrection: Mode = async (cases) => {
if (!process.env.LLM_API_KEY) return { passed: 0, failed: [], pending: "set LLM_API_KEY to run this set" }
const llm = new LlmModule({ provider: "claude", apiKey: process.env.LLM_API_KEY, model: process.env.LLM_MODEL ?? "claude-sonnet-4-6" })
const stub = (name: string, description: string): Tool => ({ name, description, effect: "write", schema: z.object({ agent: z.string().optional() }), execute: async () => ({}) })
const tools = [
stub("ranch__list_agents", "List every agent on the ranch with its state."),
stub("ranch__restart_agent", "Restart one agent by name or id."),
stub("ranch__delete_agent", "Delete one agent by name or id."),
]
const failed: string[] = []
for (const c of cases) {
const spec = c.instinct as { trigger: string; action: string; source: "correction" }
const service = new InstinctService(nullInstinctGateway(), DEFAULT_INSTINCT_CONFIG)
service.create({ trigger: spec.trigger, action: spec.action, source: spec.source, evidence: [{ ref: "session:eval", ts: Date.now() }] })
const system = `You are a helpful operations assistant with tools. Be concise.\n\n# Learned from experience\n\n${service.render()}`
const history: Event[] = [{ id: c.id, type: "user", ts: Date.now(), data: { text: String(c.message), from: "eval" } }]
const response = await llm.getGateway().complete(system, history, tools)
const expect = c.expect as { toolCall?: string | null; contains?: string; notContains?: string; maxSentences?: number }
const calls = response.toolCalls?.map(t => t.name) ?? []
const text = response.text ?? ""
const problems: string[] = []
if (expect.toolCall === null && calls.length > 0) problems.push(`called ${calls.join("+")}`)
if (typeof expect.toolCall === "string" && !calls.includes(expect.toolCall)) problems.push(`no ${expect.toolCall}`)
if (expect.contains && !re(expect.contains).test(text)) problems.push("text mismatch")
if (expect.notContains && re(expect.notContains).test(text)) problems.push("forbidden text")
if (expect.maxSentences !== undefined && (text.match(/[.!?](\s|$)/g)?.length ?? 1) > expect.maxSentences) problems.push("too long")
if (problems.length) failed.push(`${c.id}(${problems.join(";")})`)
}
return { passed: cases.length - failed.length, failed }
}

/** Case patterns may start with `(?i)` for case-insensitive matching (JS has no inline flags). */
function re(pattern: string): RegExp {
return pattern.startsWith("(?i)") ? new RegExp(pattern.slice(4), "iu") : new RegExp(pattern, "u")
}

function nullInstinctGateway(): IInstinctGateway {
return { list: () => [], write: () => {}, remove: () => {}, readLastDecay: () => undefined, writeLastDecay: () => {}, writeSkill: () => "" }
}

/** Assertion modes, one per story (US5 no-false-done lands with its story). */
const MODES: Record<string, Mode> = { blocked, "skill-loaded": skillLoaded, "holds-after-correction": holdsAfterCorrection }

function loadSet(file: string): IEvalSet {
const lines = readFileSync(file, "utf8").split("\n").filter(l => l.trim())
Expand Down
36 changes: 18 additions & 18 deletions specs/001-ecc-agent-principles/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,27 +127,27 @@ CleanSlice vertical slices under `src/slices/{setup,agent,bot,runtime}/<slice>/{

### Tests for User Story 3

- [ ] T045 [P] [US3] Spec: starting confidence per source, `reinforce` +0.05 cap 0.95, `correct` −0.2 floor 0.1, decay −0.05 per `decayDays`, pinned never decays, band boundaries, conflict marks the lower one `conflictsWith`, prune candidates below 0.3 for > 2×decayDays, in `src/slices/agent/instinct/domain/instinct.service.spec.ts`
- [ ] T046 [P] [US3] Spec: gateway round-trips the frontmatter schema from contracts/commands-and-config.md and ignores malformed files with one warning, in `src/slices/agent/instinct/data/instinct.gateway.spec.ts`
- [ ] T047 [P] [US3] Spec: `render()` emits core as standing rules, apply as "Learned behaviours", omits suggested, honours `maxInjected`, in `src/slices/agent/instinct/domain/instinct.service.spec.ts`
- [ ] T048 [P] [US3] Spec: review pass output in instinct shape is parsed into `create()` calls; an explicit "always …" line from an admin creates a pinned core instinct, in `src/slices/agent/memory/domain/memory.service.spec.ts`
- [X] T045 [P] [US3] Spec: starting confidence per source, `reinforce` +0.05 cap 0.95, `correct` −0.2 floor 0.1, decay −0.05 per `decayDays`, pinned never decays, band boundaries, conflict marks the lower one `conflictsWith`, prune candidates below 0.3 for > 2×decayDays, in `src/slices/agent/instinct/domain/instinct.service.spec.ts`
- [X] T046 [P] [US3] Spec: gateway round-trips the frontmatter schema from contracts/commands-and-config.md and ignores malformed files with one warning, in `src/slices/agent/instinct/data/instinct.gateway.spec.ts`
- [X] T047 [P] [US3] Spec: `render()` emits core as standing rules, apply as "Learned behaviours", omits suggested, honours `maxInjected`, in `src/slices/agent/instinct/domain/instinct.service.spec.ts`
- [X] T048 [P] [US3] Spec: review pass output in instinct shape is parsed into `create()` calls; an explicit "always …" line from an admin creates a pinned core instinct, in `src/slices/agent/memory/domain/memory.service.spec.ts`

### Implementation for User Story 3

- [ ] T049 [P] [US3] Create `IInstinct`, `IEvidence`, `InstinctSource`, `InstinctBand`, `IInstinctConfig` in `src/slices/agent/instinct/domain/instinct.types.ts`
- [ ] T050 [P] [US3] Create `IInstinctGateway` (`list()`, `write(instinct)`, `remove(id)`) in `src/slices/agent/instinct/domain/instinct.gateway.ts`
- [ ] T051 [US3] Implement `InstinctService` (`create`, `reinforce`, `correct`, `decayAll(now)`, `pin`, `unpin`, `prune`, `promote(ids) → skill content`, `conflicts()`, `render(maxInjected)`, `summary()`) in `src/slices/agent/instinct/domain/instinct.service.ts`
- [ ] T052 [US3] Implement the file gateway over `.agent/memory/instincts/<id>.md` with `gray-matter` in `src/slices/agent/instinct/data/instinct.gateway.ts`
- [ ] T053 [US3] Write the review prompt that asks the aux model for corrections, error resolutions, repeated workflows and explicit admin rules in instinct shape (trigger / action / source / evidence refs), replacing the `[fact]/[workflow]` prompt, in `src/slices/agent/instinct/domain/prompts/instinct-review.prompt.ts`
- [ ] T054 [US3] Create `InstinctModule` and `index.ts` in `src/slices/agent/instinct/instinct.module.ts` and `src/slices/agent/instinct/index.ts`
- [ ] T055 [US3] Retarget `MemoryService.doReview` to call the instinct prompt, attach the turn's tool-call records as evidence, and hand results to `InstinctService.create`; keep `MEMORY.md ## Learned` writes only for `[fact]` lines, in `src/slices/agent/memory/domain/memory.service.ts` and `src/slices/agent/memory/memory.module.ts`
- [ ] T056 [US3] Reinforce on use: when a turn ends without a correction and an `apply`/`core` instinct was injected, call `reinforce` for the injected ids (loop passes the injected list via `ILoopContext`), in `src/slices/runtime/loop/domain/loop.service.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [ ] T057 [US3] Inject `render()` output under `# Memory` in `buildSystemPrompt` with its own `instincts` budget section in `src/slices/agent/agent/domain/agent.service.ts`
- [ ] T058 [US3] Run `decayAll` from the heartbeat tick (once per day, persisted `lastDecay` in `.agent/data/instincts.json`) in `src/slices/agent/heartbeat/heartbeat.module.ts` and wire in `src/slices/runtime/runtime/runtime.module.ts`
- [ ] T059 [US3] Add `/learned`, `/learned show <id>`, `/learned pin|unpin <id>`, `/learned prune [<id>]`, `/learned promote <id…>` (writes via the skill gateway to `.agent/skills/learned-<slug>/SKILL.md`, marks `promotedTo`, reloads skills), `/learned export` (attachment), in `src/slices/bot/command/domain/command.service.ts`
- [ ] T060 [US3] Add the `learned` summary to `agent_status` in `src/slices/agent/tool/data/repositories/status/agent_status.repository.ts`
- [ ] T061 [US3] Create the corrections eval set (correction turn + 10 follow-ups each, expected behaviour) in `tests/evals/corrections.jsonl` and the `holds-after-correction` assertion mode in `scripts/eval.ts`
- [ ] T062 [US3] Document instincts (bands, numbers, commands, FR-016 guarantee) in a "Learning" section of `README.md`
- [X] T049 [P] [US3] Create `IInstinct`, `IEvidence`, `InstinctSource`, `InstinctBand`, `IInstinctConfig` in `src/slices/agent/instinct/domain/instinct.types.ts`
- [X] T050 [P] [US3] Create `IInstinctGateway` (`list()`, `write(instinct)`, `remove(id)`) in `src/slices/agent/instinct/domain/instinct.gateway.ts`
- [X] T051 [US3] Implement `InstinctService` (`create`, `reinforce`, `correct`, `decayAll(now)`, `pin`, `unpin`, `prune`, `promote(ids) → skill content`, `conflicts()`, `render(maxInjected)`, `summary()`) in `src/slices/agent/instinct/domain/instinct.service.ts`
- [X] T052 [US3] Implement the file gateway over `.agent/memory/instincts/<id>.md` with `gray-matter` in `src/slices/agent/instinct/data/instinct.gateway.ts`
- [X] T053 [US3] Write the review prompt that asks the aux model for corrections, error resolutions, repeated workflows and explicit admin rules in instinct shape (trigger / action / source / evidence refs), replacing the `[fact]/[workflow]` prompt, in `src/slices/agent/instinct/domain/prompts/instinct-review.prompt.ts`
- [X] T054 [US3] Create `InstinctModule` and `index.ts` in `src/slices/agent/instinct/instinct.module.ts` and `src/slices/agent/instinct/index.ts`
- [X] T055 [US3] Retarget `MemoryService.doReview` to call the instinct prompt, attach the turn's tool-call records as evidence, and hand results to `InstinctService.create`; keep `MEMORY.md ## Learned` writes only for `[fact]` lines, in `src/slices/agent/memory/domain/memory.service.ts` and `src/slices/agent/memory/memory.module.ts`
- [X] T056 [US3] Reinforce on use: when a turn ends without a correction and an `apply`/`core` instinct was injected, call `reinforce` for the injected ids (loop passes the injected list via `ILoopContext`), in `src/slices/runtime/loop/domain/loop.service.ts` and `src/slices/runtime/runtime/domain/runtime.service.ts`
- [X] T057 [US3] Inject `render()` output under `# Memory` in `buildSystemPrompt` with its own `instincts` budget section in `src/slices/agent/agent/domain/agent.service.ts`
- [X] T058 [US3] Run `decayAll` from the heartbeat tick (once per day, persisted `lastDecay` in `.agent/data/instincts.json`) in `src/slices/agent/heartbeat/heartbeat.module.ts` and wire in `src/slices/runtime/runtime/runtime.module.ts`
- [X] T059 [US3] Add `/learned`, `/learned show <id>`, `/learned pin|unpin <id>`, `/learned prune [<id>]`, `/learned promote <id…>` (writes via the skill gateway to `.agent/skills/learned-<slug>/SKILL.md`, marks `promotedTo`, reloads skills), `/learned export` (attachment), in `src/slices/bot/command/domain/command.service.ts`
- [X] T060 [US3] Add the `learned` summary to `agent_status` in `src/slices/agent/tool/data/repositories/status/agent_status.repository.ts`
- [X] T061 [US3] Create the corrections eval set (correction turn + 10 follow-ups each, expected behaviour) in `tests/evals/corrections.jsonl` and the `holds-after-correction` assertion mode in `scripts/eval.ts`
- [X] T062 [US3] Document instincts (bands, numbers, commands, FR-016 guarantee) in a "Learning" section of `README.md`

**Checkpoint**: US3 works independently of US2; `bun test` green; corrections eval ≥9/10.

Expand Down
2 changes: 1 addition & 1 deletion src/slices/agent/agent/agent.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,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 }): Promise<IBuiltPrompt> {
async buildPrompt(opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string; instincts?: string }): Promise<IBuiltPrompt> {
return this.service.buildPrompt(this.agentDir, opts)
}
}
9 changes: 8 additions & 1 deletion src/slices/agent/agent/domain/agent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface BuildPromptOpts {
* the conversation is happening (Telegram DM, Telegram group, Slack, etc.).
*/
channelContext?: string
/** Rendered learned behaviours (`InstinctModule.render()`), CLEAN-150 US3. */
instincts?: string
}

const ADMIN_BLOCK_RE = /<!--\s*admin-only\s*-->[\s\S]*?<!--\s*\/admin-only\s*-->/g
Expand Down Expand Up @@ -107,6 +109,10 @@ export class AgentService {
}
if (memory) push("memory", `# Memory\n\n${memory}`)

// Learned behaviours with confidence (CLEAN-150 US3): standing rules and
// apply-band behaviours, rendered by the instinct slice.
if (opts?.instincts?.trim()) push("instincts", `# Learned from experience\n\n${opts.instincts.trim()}`)

if (opts?.dailyMemory) {
push("dailyNotes", `## Recent Notes\n\n${opts.dailyMemory}`)
}
Expand Down Expand Up @@ -157,7 +163,7 @@ export class AgentService {
return { prompt, sections }
}

async buildPrompt(agentDir: string, opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string }): Promise<IBuiltPrompt> {
async buildPrompt(agentDir: string, opts?: { userId?: string; toolingPrompt?: string; secretKeys?: string[]; dailyMemory?: string; skills?: SkillSummary[]; isAdmin?: boolean; extraHint?: string; integratorPrompt?: string; channelContext?: string; instincts?: string }): Promise<IBuiltPrompt> {
const config = await this.load(agentDir)

// Override user context with per-user file if it exists
Expand All @@ -176,6 +182,7 @@ export class AgentService {
extraHint: opts?.extraHint,
integratorPrompt: opts?.integratorPrompt,
channelContext: opts?.channelContext,
instincts: opts?.instincts,
})
}
}
9 changes: 9 additions & 0 deletions src/slices/agent/guard/domain/guard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ export class GuardService {
return partial ? masked.slice(0, partial.index) : masked
}

/**
* Deep-mask credentials in a value that is about to be persisted (a
* tool_call's params, a tool_result). The transcript is a log that syncs
* to S3; a token in it is a token leaked, whatever the model saw.
*/
redactValue(value: unknown): unknown {
return this.outgoingRedactionActive() ? redactDeep(value).value : value
}

private outgoingRedactionActive(): boolean {
return this.active("after").some(r => r.action === "redact" && !r.match.result && matchesTool(r.match.tool, "*"))
}
Expand Down
5 changes: 5 additions & 0 deletions src/slices/agent/guard/guard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export class GuardModule {
return this.service.redactStreaming(accumulated)
}

/** Deep-mask credential-shaped strings in any value — for what gets written to disk. */
redactValue(value: unknown): unknown {
return this.service.redactValue(value)
}

listRules(): IRule[] {
return this.service.listRules()
}
Expand Down
Loading