docs(examples): make self-improvement demo genuinely self-evolving (#29)

This commit is contained in:
Zhang Jason authored and GitHub committed 2026-07-22 22:04:52 +08:00
1 parent 6d25adfef4
commit 81d8c0045c
8 files changed
+728 -102

No files matched your search

+90 -37
View File
@@ -3,36 +3,60 @@
# Example: an Agent that improves itself (local, on an AMD GPU via Ollama)
This example is the **"Recursive Self-Improvement"** pillar in runnable code. Using only the
PenguinHarness SDK, it runs one turn of the self-improvement loop:
PenguinHarness SDK, the agent runs the self-improvement loop on **itself** — and the key point is
that the improvement is authored by the *agent*, not hardcoded by the script:
1. **Evaluate** a constrained writing task and score it against a rubric.
2. **Diagnose** which rubric points were lost (from the run itself).
3. **Edit** the agent's own `AGENTS.md` to address the failure (version N+1).
4. **Re-evaluate** the same task and keep the change only if the score improved.
1. **Evaluate** — the agent attempts a constrained task and is scored against a rubric (plain code).
2. **Diagnose** — *the agent itself* reads a failing result next to a passing example.
3. **Edit** — *the agent itself* infers the missing rule and writes it into its own `AGENTS.md`.
4. **Re-evaluate** — the same task again; the change is kept only if the mean score improved.
Everything runs on a **local open-weight model** — `qwen3:8b` served by Ollama — so no cloud API
Everything runs on a **local open-weight model** — `qwen3.6:35b` served by Ollama — so no cloud API
and no data leaving the machine. Ollama's ROCm backend runs this natively on AMD GPUs.
## Why a deterministic rubric, and why averaging
## The three scripts (from mechanism to real self-evolution)
- The **rubric is plain code you can read** (`score()` in `self-improve.ts`): file actually
written · overview ≤ 2 sentences · exactly 3 bullets · under 60 words · key facts present. No
hidden judge — the before/after numbers are objective and reproducible. In the full product the
Evaluator is driven by the `agent-evaluation` skill against a *private* rubric; this example
distills that idea to its runnable core.
- A local model is **nondeterministic**, so the example runs each version several times and
averages — which is exactly why real benchmarks use a `runs` count per case. A single run can
swing; the mean is what tells you whether the edit actually helped.
| Script | What it demonstrates | `AGENTS.md` edited by |
| --- | --- | --- |
| `self-improve.ts` | The **scoring loop** in miniature (simplest) | the **script** (hardcoded) |
| `self-evolve.ts` | **Genuine** single-round self-evolution | the **agent** itself |
| `self-evolve-recursive.ts` | **Multi-round recursion** — the main line (`pnpm start`) | the **agent**, over two rounds |
`self-improve.ts` is the honest baseline: it shows the evaluate → edit → re-evaluate machinery, but
the edit is a human-written `DISCIPLINE` string the script writes to disk. That demonstrates the
*loop*, not self-evolution. The two `self-evolve*` scripts move the diagnosis **and** the edit into
the agent — the script only supplies a failure signal and one worked example; the model does the
learning and persists it into its own identity file.
## The task, and why the baseline stably fails
The task looks trivial: *summarize `notes.txt` into `summary.md` with a 2-sentence overview and
exactly 3 key facts — and follow your team's standard report format.* The catch is the last clause:
the "team format" is an **arbitrary house convention** (a marker line, a `# Report: <subject>`
title, a `Classification: INTERNAL` line, a `Reviewed-by: Aurora Team` footer) that appears **only
in `AGENTS.md`** and **cannot be inferred from the task**.
This is the design trick that makes the baseline *stably* bad — for any model, strong or weak. The
rubric (`score()`, plain code) has 10 atomic points: **5 content points** any capable model earns
from the task alone, and **5 convention points** that are knowable only from `AGENTS.md`. With a
blank `AGENTS.md` the agent cannot guess the convention, so it stably loses those 5 points. This is
an **information gap, not a capability gap** — the reason a stronger model can't just "figure it
out". It also mirrors the full product, where the Evaluator is driven by the `agent-evaluation`
skill against a *private* rubric.
The agent closes the gap by **learning the convention from a passing example** and writing it into
its own `AGENTS.md` — and in the recursive script, by locking down the fixed constants once it has
seen several examples.
## 1–2. Serve the model and point PenguinHarness at it
```bash
export HIP_VISIBLE_DEVICES=0 # optional: pin a specific AMD GPU
ollama serve &
ollama pull qwen3:8b
ollama pull qwen3.6:35b
penguin config model add \
--model-id qwen3:8b \
--model-id qwen3.6:35b \
--provider custom --client-type openai \
--base-url http://localhost:11434/v1 \
--api-key ollama --set-default
@@ -43,35 +67,64 @@ penguin config model add \
```bash
pnpm install
pnpm build
pnpm --dir examples/self-improving-agent start
# or directly: npx tsx examples/self-improving-agent/self-improve.ts
pnpm --dir examples/self-improving-agent start # the recursive main line
# or a single round: npx tsx examples/self-improving-agent/self-evolve.ts
# or the scoring-loop baseline: npx tsx examples/self-improving-agent/self-improve.ts
```
## What you should see
```text
BASELINE (blank AGENTS.md): 3 runs
run 1: 0/5
run 2: 0/5
run 3: 0/5
BASELINE mean: 0.00/5
N+1 (with working discipline): 3 runs
run 1: 5/5
run 2: 5/5
run 3: 5/5
N+1 mean: 5.00/5
=== Self-improvement result ===
baseline: 0.00/5 → N+1: 5.00/5
Mean score improved — keep version N+1. ✔
N BASELINE (blank AGENTS.md): 5 runs
... scores: [5, 4, 5, 5, 5] mean: 4.80/10 # loses every convention point
=== REFLECT round 1: infer STRUCTURE from a single accepted report ===
----- after round 1: AGENTS.md the agent authored -----
## Report-Publishing Convention ...
<!-- <UPPERCASE_PROJECT_ID> --> # structure learned, constants still guessed
Reviewed-by: <Team Name>
N+1 (structure learned): 5 runs
... scores: [6, 7, 6, 6, 8] mean: 6.60/10
=== REFLECT round 2: RECURSE on own AGENTS.md — lock CONSTANTS from 3 examples ===
----- after round 2: AGENTS.md the agent authored -----
### Fixed Constants (verbatim)
| 1 | <!-- ACME-DATA-PLATFORM --> | # constants now locked to literals
| Last line | Reviewed-by: Aurora Team |
N+2 (constants locked): 5 runs
... scores: [10, 10, 9, 10, 10] mean: 9.80/10
=== Recursive self-evolution trajectory ===
N (baseline): 4.80/10
N+1 (structure): 6.60/10 (+1.80)
N+2 (constants): 9.80/10 (+3.20)
Monotonic improvement across two self-authored rounds — recursive self-evolution. ✔
```
With a blank `AGENTS.md`, `qwen3:8b` tends to *narrate* the summary in chat and never call the
tool to write the file — so the rubric scores it 0. Adding a short "working discipline" section
(read first, restate the constraints, actually write the file, self-check) flips that. Exact
numbers vary run to run; the averaged direction is the point.
The trajectory is the point: from one passing example the agent infers the **structure** but can't
tell which tokens are fixed constants vs per-report fields (a single example is ambiguous), so it
stalls mid-way. Given **several** accepted reports that share the same marker and sign-off, it
infers "whatever is identical across all of them is a fixed constant", reads its **own** round-1
`AGENTS.md`, and refines it — recursion in the true sense: `state_{n+1} = agent.reflect(state_n,
new_evidence)`. Exact numbers vary run to run; the monotonic direction is the point.
## Honest boundaries
- **Self-evolution needs a capable model.** Authoring a correct rule and then *following it* is
harder than benefiting from a hardcoded one. A weaker model (e.g. `qwen3:8b`) often infers the
rule but executes it unreliably (placeholders left unfilled, or narrating instead of writing the
file), so its N+1 can *regress* and the script rolls back. That rollback is the loop being honest,
not a bug.
- **Evidence sufficiency is real.** N+1 stalls at a middling score because one example genuinely
under-determines the fixed constants; N+2 only climbs because more examples make them inferable.
The gain is information-driven learning, not noise.
## Notes
- Uses a dedicated agent id (`self-improve-demo`), created on the fly — your own agents are
untouched.
- Re-running updates that demo agent in place.
- The script never writes the convention itself; it only supplies the failing report, the accepted
example(s), and the keep/roll-back signal.
- Re-running resets the demo agent's `AGENTS.md` to blank and starts the loop over.
+80 -34
View File
@@ -2,34 +2,55 @@
# 示例:一个自我改进的 Agent(本地,经 Ollama 跑在 AMD GPU 上)
这个示例是 **“递归自我进化”** 支柱的可运行版本。仅用 PenguinHarness SDK,它会跑完自我进化循环的一轮:
这个示例是 **“递归自我进化”** 支柱的可运行版本。仅用 PenguinHarness SDK,Agent 就在**自己身上**跑完
自我进化循环——而关键在于:这次改进是由 *Agent 自己*产出的,不是脚本硬编码写死的:
1. **评估(Evaluate)** —— 让 Agent 完成一个约束型写作任务,并按 rubric 打分。
2. **诊断(Diagnose)** —— 从运行结果中看出哪些 rubric 项失了分。
3. **编辑(Edit)** —— 改写 Agent 自己的 `AGENTS.md` 来修复失分点(版本 N+1)。
4. **重新评估(Re-evaluate)** —— 重跑同一任务,只有分数提升时才保留这次改动。
1. **评估(Evaluate)** —— Agent 尝试一个约束型任务,并按 rubric(普通代码)打分。
2. **诊断(Diagnose)** —— *Agent 自己*把一次失败结果与一份通过范例并排对比。
3. **编辑(Edit)** —— *Agent 自己*归纳出缺失的规则,并写进它自己的 `AGENTS.md`。
4. **重新评估(Re-evaluate)** —— 重跑同一任务;只有平均分提升时才保留这次改动。
全程跑在一个**本地开源权重模型**上——由 Ollama 提供的 `qwen3:8b`——所以不用任何云端 API、数据也不
全程跑在一个**本地开源权重模型**上——由 Ollama 提供的 `qwen3.6:35b`——所以不用任何云端 API、数据也不
离开本机。Ollama 的 ROCm 后端能原生在 AMD GPU 上运行它。
## 为什么用确定性 rubric,为什么要取平均
## 三个脚本(从机制到真正的自我进化)
- **rubric 就是你能读懂的普通代码**(`self-improve.ts` 里的 `score()`):文件是否确实写出 · 概述
≤ 2 句 · 恰好 3 条要点 · 不超过 60 词 · 关键事实是否出现。没有隐藏的裁判——前后分数是客观、可复现的。
在完整产品里,Evaluator 由 `agent-evaluation` skill 按一份*私有* rubric 驱动;这个示例把那个理念
蒸馏成可运行的核心。
- 本地模型是**非确定性**的,所以示例对每个版本各跑多次取平均——这正是真实 benchmark 里为每个 Case
设置 `runs`(多次运行)的原因。单次可能上下波动;均值才能告诉你这次编辑到底有没有帮助。
| 脚本 | 演示什么 | 由谁编辑 `AGENTS.md` |
| --- | --- | --- |
| `self-improve.ts` | **评分闭环**的微缩版(最简) | **脚本**(硬编码) |
| `self-evolve.ts` | **真正的**单轮自我进化 | **Agent** 自己 |
| `self-evolve-recursive.ts` | **多轮递归**——主线(`pnpm start`) | **Agent**,连续两轮 |
`self-improve.ts` 是诚实的基线:它展示了 评估 → 编辑 → 重新评估 的机制,但那次编辑是人写好的
`DISCIPLINE` 字符串、由脚本写到磁盘。它演示的是*循环*,不是自我进化。两个 `self-evolve*` 脚本把
诊断**和**编辑都交给 Agent——脚本只提供一个失败信号和一份范例;学习由模型完成,并被固化进它自己的
身份文件。
## 任务,以及为什么 baseline 会稳定失败
任务看起来很简单:*把 `notes.txt` 总结进 `summary.md`,含一段 2 句概述和恰好 3 条要点——并遵守你们
团队的标准报告格式。* 玄机在最后一句:这个“团队格式”是一套**任意的内部约定**(一行 marker、一个
`# Report: <subject>` 标题、一行 `Classification: INTERNAL`、一行 `Reviewed-by: Aurora Team` 页脚),
它**只出现在 `AGENTS.md` 里**,**无法从任务本身推断**。
这正是让 baseline *稳定*烂的设计诀窍——对任何模型都成立,无论强弱。rubric(`score()`,普通代码)有
10 个原子分点:**5 个内容点**任何有能力的模型仅凭任务就能拿到,**5 个约定点**只能从 `AGENTS.md` 得知。
`AGENTS.md` 为空时,Agent 猜不到约定,于是稳定丢掉那 5 分。这是**信息缺口,不是能力缺口**——也正是
为什么更强的模型也没法“自己想出来”。它也对应了完整产品的形态:那里 Evaluator 由 `agent-evaluation`
skill 按一份*私有* rubric 驱动。
Agent 通过**从一份通过范例里学到约定**、并把它写进自己的 `AGENTS.md` 来弥合这个缺口——在递归脚本里,
更进一步:见过多份范例后,把其中的固定常量锁定下来。
## 1–2. 提供模型并把 PenguinHarness 指向它
```bash
export HIP_VISIBLE_DEVICES=0 # 可选:指定某张 AMD GPU
ollama serve &
ollama pull qwen3:8b
ollama pull qwen3.6:35b
penguin config model add \
--model-id qwen3:8b \
--model-id qwen3.6:35b \
--provider custom --client-type openai \
--base-url http://localhost:11434/v1 \
--api-key ollama --set-default
@@ -40,33 +61,58 @@ penguin config model add \
```bash
pnpm install
pnpm build
pnpm --dir examples/self-improving-agent start
# 或直接运行: npx tsx examples/self-improving-agent/self-improve.ts
pnpm --dir examples/self-improving-agent start # 递归主线
# 或单轮: npx tsx examples/self-improving-agent/self-evolve.ts
# 或评分闭环基线: npx tsx examples/self-improving-agent/self-improve.ts
```
## 你应当看到什么
```text
BASELINE (blank AGENTS.md): 3 runs
run 1: 0/5
run 2: 0/5
run 3: 0/5
BASELINE mean: 0.00/5
N+1 (with working discipline): 3 runs
run 1: 5/5
run 2: 5/5
run 3: 5/5
N+1 mean: 5.00/5
=== Self-improvement result ===
baseline: 0.00/5 → N+1: 5.00/5
Mean score improved — keep version N+1. ✔
N BASELINE (blank AGENTS.md): 5 runs
... scores: [5, 4, 5, 5, 5] mean: 4.80/10 # 稳定丢掉每个约定点
=== REFLECT round 1: infer STRUCTURE from a single accepted report ===
----- after round 1: AGENTS.md the agent authored -----
## Report-Publishing Convention ...
<!-- <UPPERCASE_PROJECT_ID> --> # 学到结构,但常量还是猜的
Reviewed-by: <Team Name>
N+1 (structure learned): 5 runs
... scores: [6, 7, 6, 6, 8] mean: 6.60/10
=== REFLECT round 2: RECURSE on own AGENTS.md — lock CONSTANTS from 3 examples ===
----- after round 2: AGENTS.md the agent authored -----
### Fixed Constants (verbatim)
| 1 | <!-- ACME-DATA-PLATFORM --> | # 常量被锁定为字面量
| Last line | Reviewed-by: Aurora Team |
N+2 (constants locked): 5 runs
... scores: [10, 10, 9, 10, 10] mean: 9.80/10
=== Recursive self-evolution trajectory ===
N (baseline): 4.80/10
N+1 (structure): 6.60/10 (+1.80)
N+2 (constants): 9.80/10 (+3.20)
Monotonic improvement across two self-authored rounds — recursive self-evolution. ✔
```
当 `AGENTS.md` 为空时,`qwen3:8b` 往往会在对话里*叙述*摘要,却从不调用工具把文件写出来——于是
rubric 判它 0 分。加上一小段“任务纪律”(先读原文、把约束逐条列出、真正把文件写出来、结束前自检)就能
扭转这一点。具体数字每次运行会有波动;重点是取平均后的方向。
重点在这条轨迹:只有一份通过范例时,Agent 能推断出**结构**,却分不清哪些 token 是固定常量、哪些是
每份报告要替换的字段(单个样本本就有歧义),于是卡在中途。给它**多份**共享同一 marker 和页脚的通过
范例后,它推断出“凡是在所有样本里都完全相同的,就是固定常量”,读取自己**上一轮**写的 `AGENTS.md`
再精炼它——这才是真正意义上的递归:`state_{n+1} = agent.reflect(state_n, 新证据)`。具体数字每次运行
会有波动;重点是那条单调上升的方向。
## 诚实的边界
- **自我进化需要一个足够强的模型。** 自己写出一条正确规则、然后*遵守它*,比坐享一条硬编码规则更难。
较弱的模型(例如 `qwen3:8b`)常常能推断出规则却执行不稳(占位符没填、或者只叙述而不真正写文件),
于是它的 N+1 可能*倒退*,脚本随之回滚。那次回滚是循环在诚实工作,不是 bug。
- **证据充分性是真实的。** N+1 卡在中位,是因为单份范例确实无法确定固定常量;N+2 能冲高,只因更多
范例让常量变得可推断。这份提升是信息驱动的学习,不是噪声。
## 说明
- 使用一个专用 agent id(`self-improve-demo`),运行时即时创建——你自己的 Agent 不会被动到。
- 再次运行会就地更新这个 demo agent。
- 脚本从不自己写那套约定;它只提供失败报告、通过范例,以及保留/回滚的信号。
- 再次运行会把这个 demo agent 的 `AGENTS.md` 重置为空,并重新开始循环。
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"start": "tsx self-improve.ts"
"start": "tsx self-evolve-recursive.ts"
},
"dependencies": {
"@prismshadow/penguin-core": "workspace:*"
@@ -0,0 +1,287 @@
/**
* RECURSIVE self-evolution — the agent improves its OWN AGENTS.md over MULTIPLE rounds, each round
* building on the state it authored in the previous round.
*
* Why a second round is needed (and is the interesting part):
* In round 1 the agent sees ONE accepted report (Borealis). It can infer the STRUCTURE
* (marker line / title / classification / footer) but it CANNOT know which tokens are fixed
* constants vs per-report variables — a single example is ambiguous. So it generalizes the
* marker to a placeholder and guesses the sign-off, and stably lands mid (~7/10).
*
* In round 2 the agent is given SEVERAL accepted reports from DIFFERENT projects that all share
* the SAME marker (`<!-- ACME-DATA-PLATFORM -->`) and the SAME footer (`Reviewed-by: Aurora
* Team`). Now the invariants are inferable: whatever is identical across every accepted report is
* a FIXED constant; whatever varies is a field. The agent reads its OWN round-1 AGENTS.md and
* REFINES it to lock those constants down → N+2 climbs to near-ceiling.
*
* This is recursion in the true sense: state_{n+1} = agent.reflect(state_n, new_evidence). The
* script never writes a rule; it only supplies evidence and the keep/rollback signal.
*
* Run: cd examples/self-improving-agent && pnpm exec tsx self-evolve-recursive.ts
*/
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createAgent,
userText,
isCompleteModelMessage,
type OmniMessage,
} from "@prismshadow/penguin-core";
const AGENT_ID = "self-improve-demo";
const PROJECT_ID = "default_project";
const TASK = `Read notes.txt in your workspace and write summary.md that summarizes the notes with:
(1) an overview of at most 2 sentences, and
(2) a bullet list of exactly 3 key facts.
Follow your team's standard report format.`;
const NOTES = `Project Aurora — Internal Notes
Aurora is a real-time analytics platform launched in Q1 2026. At peak it ingests roughly
2 million events per second through a Kafka-based pipeline. The core query engine was rewritten
in Rust after the original Go version could not keep p99 latency under control; the rewrite cut
p99 from 800ms to 120ms.
The production deployment is single-region with no failover; a multi-region rollout is scheduled
for Q3 2026. Storage is the largest cost line at about $48,000/month, driven by a 90-day
hot-retention policy; cutting retention to 30 days would reduce storage cost by roughly 55%.
`;
/** Round 1: a SINGLE accepted report. Structure is inferable; the fixed constants are ambiguous. */
const REFERENCE_1 = `<!-- ACME-DATA-PLATFORM -->
# Report: Project Borealis
Classification: INTERNAL
Borealis is a batch ETL platform migrated to Spark in 2025; it processes about 40TB nightly.
- Cut the nightly window from 6h to 90 minutes
- Runs on a 200-node autoscaling cluster
- Compute costs roughly $12,000/month
Reviewed-by: Aurora Team
`;
/** Round 2: SEVERAL accepted reports from different projects. The marker and the sign-off are
* IDENTICAL across all of them (→ fixed constants); the title subject and body vary (→ fields). */
const REFERENCE_2 = `<!-- ACME-DATA-PLATFORM -->
# Report: Project Cascade
Classification: INTERNAL
Cascade is a streaming feature store rolled out in 2026 serving 500 models online.
- p99 read latency 8ms at 300k QPS
- Backed by a Redis + RocksDB tier
- Operates at about $9,500/month
Reviewed-by: Aurora Team
`;
const REFERENCE_3 = `<!-- ACME-DATA-PLATFORM -->
# Report: Project Delta
Classification: INTERNAL
Delta is an internal experimentation platform launched in Q4 2025 running 1,200 concurrent tests.
- Reduced experiment setup from days to under an hour
- Guardrail metrics evaluated hourly
- Infra footprint about $6,000/month
Reviewed-by: Aurora Team
`;
function rootDir(): string {
return process.env.PENGUIN_HOME ?? path.join(os.homedir(), ".penguin", "data");
}
function agentStateDir(): string {
return path.join(rootDir(), PROJECT_ID, "agents", AGENT_ID, "agent_state");
}
function score(summaryText: string | null): { score: number; detail: string[] } {
const detail: string[] = [];
let s = 0;
const add = (ok: boolean, label: string) => {
if (ok) s += 1;
detail.push(`${ok ? "1" : "0"}/1 ${label}`);
};
const exists = summaryText !== null;
add(exists, "file summary.md was written");
if (!exists) return { score: 0, detail };
const text = summaryText!;
const lines = text.split("\n");
const bullets = (text.match(/^\s*[-*]\s+/gm) ?? []).length;
add(bullets === 3, `exactly 3 bullet facts (found ${bullets})`);
add(text.includes("120ms"), "mentions the p99 latency figure (120ms)");
add(text.includes("2 million") || /2m\s*events/i.test(text), "mentions the throughput figure");
add(text.includes("55%") || text.includes("48"), "mentions a storage cost/retention figure");
add(lines[0]?.trim() === "<!-- ACME-DATA-PLATFORM -->", "[convention] line 1 is the ACME marker");
add(
/^#\s+Report:.*aurora/i.test((lines[1] ?? "").trim()),
"[convention] line 2 is '# Report: <Aurora subject>'",
);
add(
/^Classification:\s*INTERNAL\s*$/m.test(text),
"[convention] contains 'Classification: INTERNAL'",
);
const lastNonEmpty = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
add(
lastNonEmpty.trim() === "Reviewed-by: Aurora Team",
"[convention] final line is 'Reviewed-by: Aurora Team'",
);
const allFour =
lines[0]?.trim() === "<!-- ACME-DATA-PLATFORM -->" &&
/^#\s+Report:.*aurora/i.test((lines[1] ?? "").trim()) &&
/^Classification:\s*INTERNAL\s*$/m.test(text) &&
lastNonEmpty.trim() === "Reviewed-by: Aurora Team";
add(allFour, "[convention] full team format satisfied end-to-end");
return { score: s, detail };
}
async function collect(run: AsyncGenerator<OmniMessage>): Promise<void> {
for await (const msg of run) {
if (isCompleteModelMessage(msg) && msg.payload.type === "text") {
/* agent chatter suppressed */
}
}
}
async function runOnce(): Promise<{ score: number; detail: string[]; report: string | null }> {
const agent = await createAgent({ agentId: AGENT_ID });
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "self-evolve-r-"));
await fs.writeFile(path.join(ws, "notes.txt"), NOTES, "utf8");
const session = await agent.createSession({ workspaceDir: ws });
await collect(session.run([userText(TASK)], { approve: async () => "allow" }));
let report: string | null = null;
try {
report = await fs.readFile(path.join(ws, "summary.md"), "utf8");
} catch {
report = null;
}
const { score: sc, detail } = score(report);
return { score: sc, detail, report };
}
const RUNS = 5;
let lastReport = "";
async function evaluate(label: string): Promise<number> {
console.log(`\n--- ${label}: ${RUNS} runs ---`);
const scores: number[] = [];
for (let i = 0; i < RUNS; i++) {
const r = await runOnce();
scores.push(r.score);
if (r.report) lastReport = r.report;
const missed = r.detail.filter((d) => d.startsWith("0")).map((d) => d.replace(/^0\/1\s+/, ""));
console.log(` run ${i + 1}: ${r.score}/10 [missed: ${missed.join("; ") || "none"}]`);
}
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
console.log(` ${label} scores: [${scores.join(", ")}] mean: ${mean.toFixed(2)}/10`);
return mean;
}
/** Round 1 reflection: infer STRUCTURE from a single accepted example + the agent's own failure. */
async function reflectRound1(failedReport: string): Promise<void> {
const agent = await createAgent({ agentId: AGENT_ID });
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "self-evolve-r1-"));
await fs.writeFile(path.join(ws, "last_report.md"), failedReport, "utf8");
await fs.writeFile(path.join(ws, "accepted_1.md"), REFERENCE_1, "utf8");
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
const prompt = `You are improving your own future performance by editing your persistent instructions.
In your workspace:
- last_report.md — a report you wrote that the team REJECTED.
- accepted_1.md — a DIFFERENT project's report that PASSED review.
Nobody has told you the rules. Read both, infer the team's reusable report-publishing convention
(marker line, title format, metadata line, footer/sign-off), and WRITE it as durable general
guidance into your AGENTS.md at this absolute path:
${agentsMd}
(Overwrite the file.) Capture the fixed lines and the per-report variable parts. Do not copy the
example's specific content. Use your file tools to read and to write, then confirm.`;
const session = await agent.createSession({ workspaceDir: ws });
await collect(session.run([userText(prompt)], { approve: async () => "allow" }));
}
/** Round 2 reflection: RECURSE on the agent's own AGENTS.md using MULTIPLE accepted examples so the
* invariants (constant marker + constant sign-off) become inferable and can be locked down. */
async function reflectRound2(): Promise<void> {
const agent = await createAgent({ agentId: AGENT_ID });
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "self-evolve-r2-"));
await fs.writeFile(path.join(ws, "accepted_1.md"), REFERENCE_1, "utf8");
await fs.writeFile(path.join(ws, "accepted_2.md"), REFERENCE_2, "utf8");
await fs.writeFile(path.join(ws, "accepted_3.md"), REFERENCE_3, "utf8");
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
const prompt = `You are refining your own report-publishing convention. Your current convention is
already written in your AGENTS.md at:
${agentsMd}
Read it first.
Your workspace now has THREE accepted reports from DIFFERENT projects: accepted_1.md,
accepted_2.md, accepted_3.md. Compare them carefully. The key insight of this round:
Anything that is IDENTICAL, character-for-character, across ALL THREE accepted reports is a
FIXED CONSTANT that you must reproduce VERBATIM in every report (for example a specific marker
line or a specific sign-off line). Anything that DIFFERS between them (like the project name in
the title, or the body text) is a per-report FIELD you fill in.
Update your AGENTS.md so it records the fixed constants as EXACT literal strings (not placeholders),
and clearly marks which parts vary per report. Overwrite the file at the path above using your file
tools. Then confirm what you changed and why.`;
const session = await agent.createSession({ workspaceDir: ws });
await collect(session.run([userText(prompt)], { approve: async () => "allow" }));
}
async function showState(tag: string): Promise<void> {
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
const txt = (await fs.readFile(agentsMd, "utf8")).trim();
console.log(`\n----- ${tag}: AGENTS.md the agent authored -----`);
console.log(txt || "(empty)");
console.log("-".repeat(48));
}
async function main(): Promise<void> {
await createAgent({ agentId: AGENT_ID });
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
await fs.writeFile(agentsMd, "", "utf8");
// N: baseline
const nBaseline = await evaluate("N BASELINE (blank AGENTS.md)");
const failed = lastReport;
// Round 1: structure from one example
console.log("\n=== REFLECT round 1: infer STRUCTURE from a single accepted report ===");
await reflectRound1(failed);
await showState("after round 1");
const nPlus1 = await evaluate("N+1 (structure learned)");
// Round 2: recurse — lock the constants from multiple examples
console.log(
"\n=== REFLECT round 2: RECURSE on own AGENTS.md — lock CONSTANTS from 3 examples ===",
);
await reflectRound2();
await showState("after round 2");
const nPlus2 = await evaluate("N+2 (constants locked)");
console.log("\n=== Recursive self-evolution trajectory ===");
console.log(` N (baseline): ${nBaseline.toFixed(2)}/10`);
console.log(
` N+1 (structure): ${nPlus1.toFixed(2)}/10 (${(nPlus1 - nBaseline >= 0 ? "+" : "") + (nPlus1 - nBaseline).toFixed(2)})`,
);
console.log(
` N+2 (constants): ${nPlus2.toFixed(2)}/10 (${(nPlus2 - nPlus1 >= 0 ? "+" : "") + (nPlus2 - nPlus1).toFixed(2)})`,
);
const monotonic = nPlus1 >= nBaseline && nPlus2 >= nPlus1;
console.log(
monotonic
? " Monotonic improvement across two self-authored rounds — recursive self-evolution. ✔"
: " Non-monotonic — inspect which round regressed.",
);
}
main().catch((err) => {
console.error("Example failed:", err);
process.exitCode = 1;
});
@@ -0,0 +1,238 @@
/**
* GENUINE self-evolution demo — the agent diagnoses its own failure and edits its OWN AGENTS.md.
*
* The original example (and the -hard variant) hardcode the improvement:
* `fs.writeFile(agentsMd, DISCIPLINE)` ← the human writes the answer, the agent just benefits.
* That demonstrates the *scoring loop*, but NOT self-evolution — the model does no diagnosing and
* authors no fix.
*
* Here the EDIT step is done BY THE AGENT ITSELF:
* 1. EVALUATE — the agent writes a report for a task that says only "follow your team's format".
* With a blank AGENTS.md it cannot know the (arbitrary, unguessable) house
* convention, so it stably loses the convention points.
* 2. REFLECT — the agent is shown (a) its own rejected report and (b) a DIFFERENT report that
* passed review. Nobody tells it the rules. It must compare the two, INFER the
* reusable convention, and write that convention into its OWN AGENTS.md.
* 3. RE-EVALUATE — the same task again; now the agent applies the rule it taught itself.
* 4. KEEP / ROLL BACK — keep the self-authored AGENTS.md only if the mean score improved.
*
* The script never writes the convention. It only supplies a failure signal + one worked example;
* the model does the diagnosis and persists its own improvement into its identity (AGENTS.md).
* The reference uses a DIFFERENT subject (Borealis) than the task (Aurora), so the agent must
* generalize the *rules*, not copy the *content*.
*
* Run: cd examples/self-improving-agent && pnpm exec tsx self-evolve.ts
*/
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createAgent,
userText,
isCompleteModelMessage,
type OmniMessage,
} from "@prismshadow/penguin-core";
const AGENT_ID = "self-improve-demo";
const PROJECT_ID = "default_project";
/** The task fully specifies content, but only says "follow your team's format" — the convention
* itself is knowable only from AGENTS.md, which the agent must author for itself in REFLECT. */
const TASK = `Read notes.txt in your workspace and write summary.md that summarizes the notes with:
(1) an overview of at most 2 sentences, and
(2) a bullet list of exactly 3 key facts.
Follow your team's standard report format.`;
const NOTES = `Project Aurora — Internal Notes
Aurora is a real-time analytics platform launched in Q1 2026. At peak it ingests roughly
2 million events per second through a Kafka-based pipeline. The core query engine was rewritten
in Rust after the original Go version could not keep p99 latency under control; the rewrite cut
p99 from 800ms to 120ms.
The production deployment is single-region with no failover; a multi-region rollout is scheduled
for Q3 2026. Storage is the largest cost line at about $48,000/month, driven by a 90-day
hot-retention policy; cutting retention to 30 days would reduce storage cost by roughly 55%.
`;
/** A gold report that PASSED review — a DIFFERENT subject (Borealis), so the agent must infer the
* reusable rules (marker/title-pattern/metadata/footer) rather than copy this report's content. */
const REFERENCE_REPORT = `<!-- ACME-DATA-PLATFORM -->
# Report: Project Borealis
Classification: INTERNAL
Borealis is a batch ETL platform migrated to Spark in 2025; it processes about 40TB nightly.
- Cut the nightly window from 6h to 90 minutes
- Runs on a 200-node autoscaling cluster
- Compute costs roughly $12,000/month
Reviewed-by: Aurora Team
`;
function rootDir(): string {
return process.env.PENGUIN_HOME ?? path.join(os.homedir(), ".penguin", "data");
}
function agentStateDir(): string {
return path.join(rootDir(), PROJECT_ID, "agents", AGENT_ID, "agent_state");
}
/** 10 atomic points: 5 CONTENT (inferable from the task) + 5 CONVENTION (knowable only from the
* house rule the agent must teach itself). Line-2 check requires the ACTUAL subject (Aurora),
* so blindly copying the Borealis reference does not earn the point. */
function score(summaryText: string | null): { score: number; detail: string[] } {
const detail: string[] = [];
let s = 0;
const add = (ok: boolean, label: string) => {
if (ok) s += 1;
detail.push(`${ok ? "1" : "0"}/1 ${label}`);
};
const exists = summaryText !== null;
add(exists, "file summary.md was written");
if (!exists) return { score: 0, detail };
const text = summaryText!;
const lines = text.split("\n");
// CONTENT points
const bullets = (text.match(/^\s*[-*]\s+/gm) ?? []).length;
add(bullets === 3, `exactly 3 bullet facts (found ${bullets})`);
add(text.includes("120ms"), "mentions the p99 latency figure (120ms)");
add(text.includes("2 million") || /2m\s*events/i.test(text), "mentions the throughput figure");
add(text.includes("55%") || text.includes("48"), "mentions a storage cost/retention figure");
// CONVENTION points — only satisfiable via the self-authored AGENTS.md rule
add(lines[0]?.trim() === "<!-- ACME-DATA-PLATFORM -->", "[convention] line 1 is the ACME marker");
add(
/^#\s+Report:.*aurora/i.test((lines[1] ?? "").trim()),
"[convention] line 2 is '# Report: <Aurora subject>'",
);
add(
/^Classification:\s*INTERNAL\s*$/m.test(text),
"[convention] contains 'Classification: INTERNAL'",
);
const lastNonEmpty = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
add(
lastNonEmpty.trim() === "Reviewed-by: Aurora Team",
"[convention] final line is 'Reviewed-by: Aurora Team'",
);
const allFour =
lines[0]?.trim() === "<!-- ACME-DATA-PLATFORM -->" &&
/^#\s+Report:.*aurora/i.test((lines[1] ?? "").trim()) &&
/^Classification:\s*INTERNAL\s*$/m.test(text) &&
lastNonEmpty.trim() === "Reviewed-by: Aurora Team";
add(allFour, "[convention] full team format satisfied end-to-end");
return { score: s, detail };
}
async function collect(run: AsyncGenerator<OmniMessage>): Promise<void> {
for await (const msg of run) {
if (isCompleteModelMessage(msg) && msg.payload.type === "text") {
// keep the demo output focused; the agent's chatter is not the point here
}
}
}
/** One evaluation run; returns the score and the report text the agent wrote (for REFLECT). */
async function runOnce(): Promise<{ score: number; detail: string[]; report: string | null }> {
const agent = await createAgent({ agentId: AGENT_ID });
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "self-evolve-"));
await fs.writeFile(path.join(ws, "notes.txt"), NOTES, "utf8");
const session = await agent.createSession({ workspaceDir: ws });
await collect(session.run([userText(TASK)], { approve: async () => "allow" }));
let report: string | null = null;
try {
report = await fs.readFile(path.join(ws, "summary.md"), "utf8");
} catch {
report = null;
}
const { score: sc, detail } = score(report);
return { score: sc, detail, report };
}
const RUNS = 5;
async function evaluate(label: string): Promise<number> {
console.log(`\n--- ${label}: ${RUNS} runs ---`);
const scores: number[] = [];
let lastReport: string | null = null;
for (let i = 0; i < RUNS; i++) {
const r = await runOnce();
scores.push(r.score);
if (r.report) lastReport = r.report;
const missed = r.detail.filter((d) => d.startsWith("0")).map((d) => d.replace(/^0\/1\s+/, ""));
console.log(` run ${i + 1}: ${r.score}/10 [missed: ${missed.join("; ") || "none"}]`);
}
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
console.log(` ${label} scores: [${scores.join(", ")}] mean: ${mean.toFixed(2)}/10`);
(evaluate as unknown as { lastReport?: string | null }).lastReport = lastReport;
return mean;
}
/** The self-evolution step: the AGENT reads its own failed report + a passing reference, infers the
* house convention, and writes it into its OWN AGENTS.md. The script provides no rules. */
async function reflectAndEditOwnState(failedReport: string): Promise<void> {
const agent = await createAgent({ agentId: AGENT_ID });
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "self-evolve-reflect-"));
await fs.writeFile(path.join(ws, "last_report.md"), failedReport, "utf8");
await fs.writeFile(path.join(ws, "reference_report.md"), REFERENCE_REPORT, "utf8");
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
const REFLECT = `You are improving your own future performance by editing your persistent instructions.
In your workspace are two files:
- last_report.md — a report you recently wrote that the team REJECTED.
- reference_report.md — a DIFFERENT report (different project) that PASSED the team's review.
Nobody has told you the formatting rules. Read BOTH files, compare them, and infer the team's
reusable report-publishing convention: any required marker line, the title format, required
metadata line(s), and the required footer/sign-off line.
Then WRITE those rules as durable, general guidance into your AGENTS.md at this absolute path:
${agentsMd}
(Overwrite the file.) Capture the GENERAL rules that apply to every future report — the exact
fixed lines that never change, and the parts that vary per report (like the subject in the title).
Do NOT copy reference_report.md's specific content. Use your file tools to read the two files and
to write AGENTS.md, then confirm you wrote it.`;
const session = await agent.createSession({ workspaceDir: ws });
await collect(session.run([userText(REFLECT)], { approve: async () => "allow" }));
}
async function main(): Promise<void> {
await createAgent({ agentId: AGENT_ID });
const agentsMd = path.join(agentStateDir(), "AGENTS.md");
await fs.writeFile(agentsMd, "", "utf8"); // start blank — the agent knows no convention yet
// 1. EVALUATE baseline
const baseline = await evaluate("BASELINE (blank AGENTS.md)");
const failed = (evaluate as unknown as { lastReport?: string | null }).lastReport ?? "";
// 2. REFLECT — the agent diagnoses and edits its OWN AGENTS.md
console.log(
"\n--- REFLECT: the agent studies a passing example and rewrites its OWN AGENTS.md ---",
);
await reflectAndEditOwnState(failed);
const authored = await fs.readFile(agentsMd, "utf8");
console.log("\n----- AGENTS.md the agent wrote for itself -----");
console.log(authored.trim() || "(empty — the agent failed to author a rule)");
console.log("-----------------------------------------------");
// 3. RE-EVALUATE with the self-authored rule
const evolved = await evaluate("N+1 (agent's self-authored convention)");
// 4. KEEP / ROLL BACK
console.log("\n=== Self-evolution result ===");
console.log(` baseline: ${baseline.toFixed(2)}/10 → N+1: ${evolved.toFixed(2)}/10`);
if (evolved > baseline) {
console.log(" The agent's self-authored edit improved its score — keep it. ✔");
} else {
console.log(" No improvement — roll back to the blank AGENTS.md.");
await fs.writeFile(agentsMd, "", "utf8");
}
}
main().catch((err) => {
console.error("Example failed:", err);
process.exitCode = 1;
});
@@ -2,7 +2,7 @@
* Example: an Agent that improves itself — one turn of the self-improvement loop, in code.
*
* This is the "Recursive Self-Improvement" pillar made runnable. It runs entirely on a local
* open-weight model (Ollama serving qwen3:8b) — see README.md for the one-time setup.
* open-weight model (Ollama serving qwen3.6:35b) — see README.md for the one-time setup.
*
* The loop, exactly as the docs describe it:
* 1. EVALUATE — run the agent on a constrained task, score it against a rubric.