chore: Spring AI 重构

This commit is contained in:
abel533 committed 2026-03-25 00:15:00 +08:00
1 parent a9c71002d2
commit 2afa4712cb
124 files changed
+11777 -3530

No files matched your search

+198 -52
View File
@@ -12,6 +12,7 @@ import { VERSION_META, VERSION_ORDER, LEARNING_PATH } from "../src/lib/constants
const WEB_DIR = path.resolve(__dirname, "..");
const REPO_ROOT = path.resolve(WEB_DIR, "..");
const AGENTS_DIR = path.join(REPO_ROOT, "agents");
const JAVA_SRC_DIR = path.join(REPO_ROOT, "src", "main", "java", "io", "mybatis", "learn");
const DOCS_DIR = path.join(REPO_ROOT, "docs");
const OUT_DIR = path.join(WEB_DIR, "src", "data", "generated");
@@ -91,12 +92,101 @@ function extractTools(source: string): string[] {
return Array.from(tools);
}
// Count non-blank, non-comment lines
// Extract classes/interfaces/records/enums from Java source
function extractJavaClasses(
lines: string[]
): { name: string; startLine: number; endLine: number }[] {
const classes: { name: string; startLine: number; endLine: number }[] = [];
const classPattern = /^(?:public\s+)?(?:class|interface|record|enum)\s+(\w+)/;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(classPattern);
if (m) {
const name = m[1];
const startLine = i + 1;
let endLine = lines.length;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].match(/^(?:public\s+)?(?:class|interface|record|enum)\s/)) {
endLine = j;
break;
}
}
classes.push({ name, startLine, endLine });
}
}
return classes;
}
// Extract methods from Java source (skipping constructors)
function extractJavaMethods(
lines: string[]
): { name: string; signature: string; startLine: number }[] {
const methods: { name: string; signature: string; startLine: number }[] = [];
const methodPattern =
/^\s+(?:(?:public|private|protected|static|default|abstract|final|synchronized|native)\s+)*(?:\w+(?:<[^>]*>)?(?:\[\])?)\s+(\w+)\s*\(/;
const classNames = new Set<string>();
const classPattern = /^(?:public\s+)?(?:class|interface|record|enum)\s+(\w+)/;
for (const line of lines) {
const m = line.match(classPattern);
if (m) classNames.add(m[1]);
}
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(methodPattern);
if (m) {
const name = m[1];
if (classNames.has(name)) continue;
const sig = lines[i].trim().replace(/\{?\s*$/, "").trim();
methods.push({ name, signature: sig, startLine: i + 1 });
}
}
return methods;
}
// Extract tool names from Java @Tool annotations
function extractJavaTools(lines: string[]): string[] {
const tools: string[] = [];
const toolAnnotation = /^\s*@Tool\b/;
const methodPattern =
/^\s+(?:(?:public|private|protected|static|default|abstract|final|synchronized|native)\s+)*(?:\w+(?:<[^>]*>)?(?:\[\])?)\s+(\w+)\s*\(/;
for (let i = 0; i < lines.length; i++) {
if (toolAnnotation.test(lines[i])) {
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
const m = lines[j].match(methodPattern);
if (m) {
tools.push(m[1]);
break;
}
}
}
}
return tools;
}
// Count non-blank, non-comment lines (supports Python # and Java // /* */ comments)
function countLoc(lines: string[]): number {
return lines.filter((line) => {
let inBlockComment = false;
let count = 0;
for (const line of lines) {
const trimmed = line.trim();
return trimmed !== "" && !trimmed.startsWith("#");
}).length;
if (trimmed === "") continue;
if (inBlockComment) {
if (trimmed.includes("*/")) inBlockComment = false;
continue;
}
if (trimmed.startsWith("/*")) {
if (!trimmed.includes("*/")) inBlockComment = true;
continue;
}
if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
count++;
}
return count;
}
// Detect locale from subdirectory path
@@ -119,60 +209,116 @@ function extractDocVersion(filename: string): string | null {
function main() {
console.log("Extracting content from agents and docs...");
console.log(` Repo root: ${REPO_ROOT}`);
console.log(` Agents dir: ${AGENTS_DIR}`);
console.log(` Java src dir: ${JAVA_SRC_DIR}`);
console.log(` Agents dir (fallback): ${AGENTS_DIR}`);
console.log(` Docs dir: ${DOCS_DIR}`);
// Skip extraction if source directories don't exist (e.g. Vercel build).
// Pre-committed generated data will be used instead.
if (!fs.existsSync(AGENTS_DIR)) {
console.log(" Agents directory not found, skipping extraction.");
const versions: AgentVersion[] = [];
const useJava = fs.existsSync(JAVA_SRC_DIR);
if (useJava) {
// 1. Read Java sources from s01~s12 + full subdirectories
const subdirs = fs
.readdirSync(JAVA_SRC_DIR)
.filter((d) => /^s\d+$/.test(d) || d === "full")
.filter((d) => fs.statSync(path.join(JAVA_SRC_DIR, d)).isDirectory());
console.log(` Found ${subdirs.length} Java lesson directories`);
for (const dir of subdirs) {
const versionId = dir;
const dirPath = path.join(JAVA_SRC_DIR, dir);
const javaFiles = fs.readdirSync(dirPath).filter((f) => f.endsWith(".java"));
if (javaFiles.length === 0) continue;
// 主类文件排在最前(如 S01AgentLoop.java)
const mainPrefix = dir === "full" ? "SFull" : "S" + dir.substring(1);
javaFiles.sort((a, b) => {
const aMain = a.startsWith(mainPrefix) ? 0 : 1;
const bMain = b.startsWith(mainPrefix) ? 0 : 1;
if (aMain !== bMain) return aMain - bMain;
return a.localeCompare(b);
});
const mainFile = javaFiles[0];
// 拼接目录下所有 Java 文件,文件间用分隔符标记
const sourceParts: string[] = [];
for (const jf of javaFiles) {
const content = fs.readFileSync(path.join(dirPath, jf), "utf-8");
sourceParts.push(`// === ${jf} ===\n${content}`);
}
const source = sourceParts.join("\n\n");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractJavaClasses(lines);
const methods = extractJavaMethods(lines);
const tools = extractJavaTools(lines);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename: mainFile,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [],
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions: methods,
layer: meta?.layer ?? "tools",
source,
});
}
} else if (fs.existsSync(AGENTS_DIR)) {
// 回退:从 agents/ 目录读取 Python 源码
const agentFiles = fs
.readdirSync(AGENTS_DIR)
.filter((f) => f.startsWith("s") && f.endsWith(".py"));
console.log(` Found ${agentFiles.length} agent files (Python fallback)`);
for (const filename of agentFiles) {
const versionId = filenameToVersionId(filename);
if (!versionId) {
console.warn(` Skipping ${filename}: could not determine version ID`);
continue;
}
const filePath = path.join(AGENTS_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractClasses(lines);
const functions = extractFunctions(lines);
const tools = extractTools(source);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [],
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions,
layer: meta?.layer ?? "tools",
source,
});
}
} else {
console.log(" Source directories not found, skipping extraction.");
console.log(" Using pre-committed generated data.");
return;
}
// 1. Read all agent files
const agentFiles = fs
.readdirSync(AGENTS_DIR)
.filter((f) => f.startsWith("s") && f.endsWith(".py"));
console.log(` Found ${agentFiles.length} agent files`);
const versions: AgentVersion[] = [];
for (const filename of agentFiles) {
const versionId = filenameToVersionId(filename);
if (!versionId) {
console.warn(` Skipping ${filename}: could not determine version ID`);
continue;
}
const filePath = path.join(AGENTS_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractClasses(lines);
const functions = extractFunctions(lines);
const tools = extractTools(source);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [], // computed after all versions are loaded
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions,
layer: meta?.layer ?? "tools",
source,
});
}
// Sort versions according to VERSION_ORDER
const orderMap = new Map(VERSION_ORDER.map((v, i) => [v, i]));
versions.sort(
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import Link from "next/link";
import { useLocale } from "@/lib/i18n";
import { useLocale, useTranslations } from "@/lib/i18n";
import { VERSION_META } from "@/lib/constants";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { LayerBadge } from "@/components/ui/badge";
@@ -19,6 +19,8 @@ interface DiffPageContentProps {
export function DiffPageContent({ version }: DiffPageContentProps) {
const locale = useLocale();
const tMeta = useTranslations("version_meta");
const td = useTranslations("diff");
const meta = VERSION_META[version];
const { currentVersion, prevVersion, diff } = useMemo(() => {
@@ -32,9 +34,9 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
if (!meta || !currentVersion) {
return (
<div className="py-12 text-center">
<p className="text-zinc-500">Version not found.</p>
<p className="text-zinc-500">{td("version_not_found")}</p>
<Link href={`/${locale}/timeline`} className="mt-4 inline-block text-sm text-blue-600 hover:underline">
Back to timeline
{td("back_to_timeline")}
</Link>
</div>
);
@@ -48,11 +50,11 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<ArrowLeft size={14} />
Back to {meta.title}
{td("back_to")} {meta.title}
</Link>
<h1 className="text-3xl font-bold">{meta.title}</h1>
<p className="mt-4 text-zinc-500">
This is the first version -- there is no previous version to compare against.
{td("first_version_hint")}
</p>
</div>
);
@@ -67,7 +69,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<ArrowLeft size={14} />
Back to {meta.title}
{td("back_to")} {meta.title}
</Link>
{/* Header */}
@@ -86,14 +88,14 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<FileCode size={16} />
<span className="text-sm">LOC Delta</span>
<span className="text-sm">{td("loc_delta")}</span>
</div>
</CardHeader>
<CardTitle>
<span className={diff.locDelta >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{diff.locDelta >= 0 ? "+" : ""}{diff.locDelta}
</span>
<span className="ml-2 text-sm font-normal text-zinc-500">lines</span>
<span className="ml-2 text-sm font-normal text-zinc-500">{td("lines")}</span>
</CardTitle>
</Card>
@@ -101,7 +103,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<Wrench size={16} />
<span className="text-sm">New Tools</span>
<span className="text-sm">{td("new_tools")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -122,7 +124,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<Box size={16} />
<span className="text-sm">New Classes</span>
<span className="text-sm">{td("new_classes")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -143,7 +145,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<FunctionSquare size={16} />
<span className="text-sm">New Functions</span>
<span className="text-sm">{td("new_functions")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -166,7 +168,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<Card className="border-l-4 border-l-red-300 dark:border-l-red-700">
<CardHeader>
<CardTitle>{prevMeta?.title || prevVersion.id}</CardTitle>
<p className="text-sm text-zinc-500">{prevMeta?.subtitle}</p>
<p className="text-sm text-zinc-500">{tMeta(`${prevVersion.id}.subtitle`)}</p>
</CardHeader>
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
<p>{prevVersion.loc} LOC</p>
@@ -177,7 +179,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<Card className="border-l-4 border-l-green-300 dark:border-l-green-700">
<CardHeader>
<CardTitle>{meta.title}</CardTitle>
<p className="text-sm text-zinc-500">{meta.subtitle}</p>
<p className="text-sm text-zinc-500">{tMeta(`${version}.subtitle`)}</p>
</CardHeader>
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
<p>{currentVersion.loc} LOC</p>
@@ -189,7 +191,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
{/* Code Diff */}
<div>
<h2 className="mb-4 text-xl font-semibold">Source Code Diff</h2>
<h2 className="mb-4 text-xl font-semibold">{td("source_diff")}</h2>
<CodeDiff
oldSource={prevVersion.source}
newSource={currentVersion.source}
@@ -21,9 +21,10 @@ export default async function VersionPage({
const diff = versionsData.diffs.find((d) => d.to === version) ?? null;
if (!versionData || !meta) {
const td = getTranslations(locale, "diff");
return (
<div className="py-20 text-center">
<h1 className="text-2xl font-bold">Version not found</h1>
<h1 className="text-2xl font-bold">{td("version_not_found")}</h1>
<p className="mt-2 text-zinc-500">{version}</p>
</div>
);
@@ -32,6 +33,7 @@ export default async function VersionPage({
const t = getTranslations(locale, "version");
const tSession = getTranslations(locale, "sessions");
const tLayer = getTranslations(locale, "layer_labels");
const tMeta = getTranslations(locale, "version_meta");
const layer = LAYERS.find((l) => l.id === meta.layer);
const pathIndex = LEARNING_PATH.indexOf(version as typeof LEARNING_PATH[number]);
@@ -55,20 +57,20 @@ export default async function VersionPage({
)}
</div>
<p className="text-lg text-zinc-500 dark:text-zinc-400">
{meta.subtitle}
{tMeta(`${version}.subtitle`)}
</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-mono">{versionData.loc} LOC</span>
<span>{versionData.tools.length} {t("tools")}</span>
{meta.coreAddition && (
<span className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs dark:bg-zinc-800">
{meta.coreAddition}
{tMeta(`${version}.coreAddition`)}
</span>
)}
</div>
{meta.keyInsight && (
<blockquote className="border-l-4 border-zinc-300 pl-4 text-sm italic text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
{meta.keyInsight}
{tMeta(`${version}.keyInsight`)}
</blockquote>
)}
</header>
@@ -15,6 +15,7 @@ const data = versionData as VersionIndex;
export default function ComparePage() {
const t = useTranslations("compare");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
const [versionA, setVersionA] = useState<string>("");
const [versionB, setVersionB] = useState<string>("");
@@ -68,7 +69,7 @@ export default function ComparePage() {
onChange={(e) => setVersionA(e.target.value)}
className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">-- select --</option>
<option value="">{t("select_placeholder")}</option>
{LEARNING_PATH.map((v) => (
<option key={v} value={v}>
{v} - {VERSION_META[v]?.title}
@@ -88,7 +89,7 @@ export default function ComparePage() {
onChange={(e) => setVersionB(e.target.value)}
className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">-- select --</option>
<option value="">{t("select_placeholder")}</option>
{LEARNING_PATH.map((v) => (
<option key={v} value={v}>
{v} - {VERSION_META[v]?.title}
@@ -106,7 +107,7 @@ export default function ComparePage() {
<Card>
<CardHeader>
<CardTitle>{metaA?.title || versionA}</CardTitle>
<p className="text-sm text-zinc-500">{metaA?.subtitle}</p>
<p className="text-sm text-zinc-500">{versionA ? tMeta(`${versionA}.subtitle`) : ""}</p>
</CardHeader>
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
<p>{infoA.loc} LOC</p>
@@ -117,7 +118,7 @@ export default function ComparePage() {
<Card>
<CardHeader>
<CardTitle>{metaB?.title || versionB}</CardTitle>
<p className="text-sm text-zinc-500">{metaB?.subtitle}</p>
<p className="text-sm text-zinc-500">{versionB ? tMeta(`${versionB}.subtitle`) : ""}</p>
</CardHeader>
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
<p>{infoB.loc} LOC</p>
+5 -3
View File
@@ -30,6 +30,8 @@ const LAYER_HEADER_BG: Record<string, string> = {
export default function LayersPage() {
const t = useTranslations("layers");
const tLayer = useTranslations("layer_labels");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
return (
@@ -63,7 +65,7 @@ export default function LayersPage() {
<h2 className="text-xl font-bold">
<span className="text-zinc-400 dark:text-zinc-600">L{index + 1}</span>
{" "}
{layer.label}
{tLayer(layer.id)}
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{t(layer.id)}
@@ -92,7 +94,7 @@ export default function LayersPage() {
</h3>
{meta?.subtitle && (
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{meta.subtitle}
{tMeta(`${id}.subtitle`)}
</p>
)}
</div>
@@ -107,7 +109,7 @@ export default function LayersPage() {
</div>
{meta?.keyInsight && (
<p className="mt-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400 line-clamp-2">
{meta.keyInsight}
{tMeta(`${id}.keyInsight`)}
</p>
)}
</Card>
+1 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata({
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const messages = metaMessages[locale] || metaMessages.en;
const messages = metaMessages[locale] || metaMessages.zh;
return {
title: messages.meta?.title || "Learn Claude Code",
description: messages.meta?.description || "Build an AI coding agent from scratch, one concept at a time",
+57 -37
View File
@@ -39,6 +39,7 @@ function getVersionData(id: string) {
export default function HomePage() {
const t = useTranslations("home");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
return (
@@ -75,51 +76,65 @@ export default function HomePage() {
<span className="h-3 w-3 rounded-full bg-red-500/70" />
<span className="h-3 w-3 rounded-full bg-yellow-500/70" />
<span className="h-3 w-3 rounded-full bg-green-500/70" />
<span className="ml-3 text-xs text-zinc-500">agent_loop.py</span>
<span className="ml-3 text-xs text-zinc-500">S01AgentLoop.java</span>
</div>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
<code>
<span className="text-purple-400">while</span>
<span className="text-zinc-500">{"// Spring AI ChatClient 自动处理工具调用循环"}</span>
{"\n"}
<span className="text-orange-300">ChatClient</span>
<span className="text-zinc-300"> chatClient = </span>
<span className="text-orange-300">ChatClient</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">builder</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">chatModel</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">defaultSystem</span>
<span className="text-zinc-500">(</span>
<span className="text-green-400">&quot;You are a coding agent.&quot;</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">defaultTools</span>
<span className="text-zinc-500">(</span>
<span className="text-purple-400">new</span>
<span className="text-zinc-300"> </span>
<span className="text-orange-300">True</span>
<span className="text-zinc-500">:</span>
<span className="text-orange-300">BashTool</span>
<span className="text-zinc-500">())</span>
{"\n"}
<span className="text-zinc-300">{" "}response = client.messages.</span>
<span className="text-blue-400">create</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">build</span>
<span className="text-zinc-500">();</span>
{"\n\n"}
<span className="text-zinc-500">{"// 一次 call() 即可: 调用模型→检测工具→执行→回传"}</span>
{"\n"}
<span className="text-orange-300">String</span>
<span className="text-zinc-300"> result = chatClient.</span>
<span className="text-blue-400">prompt</span>
<span className="text-zinc-500">()</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">user</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">messages=</span>
<span className="text-zinc-300">messages</span>
<span className="text-zinc-500">,</span>
<span className="text-zinc-300"> tools=</span>
<span className="text-zinc-300">tools</span>
<span className="text-zinc-300">userMessage</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-purple-400">{" "}if</span>
<span className="text-zinc-300"> response.stop_reason != </span>
<span className="text-green-400">&quot;tool_use&quot;</span>
<span className="text-zinc-500">:</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">call</span>
<span className="text-zinc-500">()</span>
{"\n"}
<span className="text-purple-400">{" "}break</span>
{"\n"}
<span className="text-purple-400">{" "}for</span>
<span className="text-zinc-300"> tool_call </span>
<span className="text-purple-400">in</span>
<span className="text-zinc-300"> response.content</span>
<span className="text-zinc-500">:</span>
{"\n"}
<span className="text-zinc-300">{" "}result = </span>
<span className="text-blue-400">execute_tool</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">tool_call.name</span>
<span className="text-zinc-500">,</span>
<span className="text-zinc-300"> tool_call.input</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}messages.</span>
<span className="text-blue-400">append</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">result</span>
<span className="text-zinc-500">)</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">content</span>
<span className="text-zinc-500">();</span>
</code>
</pre>
</div>
@@ -172,8 +187,13 @@ export default function HomePage() {
<h3 className="mt-3 text-sm font-semibold group-hover:underline">
{meta.title}
</h3>
{tMeta(`${versionId}.title_local`) !== meta.title && (
<p className="text-xs text-[var(--color-text-secondary)]">
{tMeta(`${versionId}.title_local`)}
</p>
)}
<p className="mt-1 text-xs text-[var(--color-text-secondary)]">
{meta.keyInsight}
{tMeta(`${versionId}.keyInsight`)}
</p>
</Card>
</Link>
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/en/");
redirect("/zh/");
}
@@ -24,8 +24,8 @@ interface Decision {
title: string;
description: string;
alternatives: string;
zh?: { title: string; description: string };
ja?: { title: string; description: string };
zh?: { title: string; description: string; alternatives?: string };
ja?: { title: string; description: string; alternatives?: string };
}
interface AnnotationFile {
@@ -63,10 +63,11 @@ function DecisionCard({
const t = useTranslations("version");
const localized =
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string } | undefined : undefined;
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string; alternatives?: string } | undefined : undefined;
const title = localized?.title || decision.title;
const description = localized?.description || decision.description;
const alternatives = localized?.alternatives || decision.alternatives;
return (
<div className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
@@ -100,13 +101,13 @@ function DecisionCard({
{description}
</p>
{decision.alternatives && (
{alternatives && (
<div className="mt-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{t("alternatives")}
</h4>
<p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{decision.alternatives}
{alternatives}
</p>
</div>
)}
+67 -2
View File
@@ -7,7 +7,7 @@ interface SourceViewerProps {
filename: string;
}
function highlightLine(line: string): React.ReactNode[] {
function highlightPythonLine(line: string): React.ReactNode[] {
const trimmed = line.trimStart();
if (trimmed.startsWith("#")) {
return [
@@ -68,7 +68,72 @@ function highlightLine(line: string): React.ReactNode[] {
});
}
function highlightJavaLine(line: string): React.ReactNode[] {
const trimmed = line.trimStart();
// 单行注释
if (trimmed.startsWith("//")) {
return [
<span key={0} className="text-zinc-400 italic">
{line}
</span>,
];
}
// 块注释行 (/* ... */, * ..., */)
if (trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/")) {
return [
<span key={0} className="text-zinc-400 italic">
{line}
</span>,
];
}
const javaKeywords = new Set([
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
"class", "const", "continue", "default", "do", "double", "else", "enum",
"extends", "final", "finally", "float", "for", "if", "implements", "import",
"instanceof", "int", "interface", "long", "native", "new", "package",
"private", "protected", "public", "record", "return", "short", "static",
"strictfp", "super", "switch", "synchronized", "throw", "throws",
"transient", "try", "var", "void", "volatile", "while", "yield",
"true", "false", "null",
]);
const parts = line.split(
/(\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|extends|final|finally|float|for|if|implements|import|instanceof|int|interface|long|native|new|package|private|protected|public|record|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|var|void|volatile|while|yield|true|false|null)\b|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\/\/.*$|@\w+|\b\d+(?:\.\d+)?[dDfFlL]?\b)/
);
return parts.map((part, idx) => {
if (!part) return null;
if (part === "this") {
return <span key={idx} className="text-purple-400">{part}</span>;
}
if (javaKeywords.has(part)) {
return <span key={idx} className="text-blue-400 font-medium">{part}</span>;
}
if (part.startsWith("//")) {
return <span key={idx} className="text-zinc-400 italic">{part}</span>;
}
if (part.startsWith("@")) {
return <span key={idx} className="text-amber-400">{part}</span>;
}
if (
(part.startsWith('"') && part.endsWith('"')) ||
(part.startsWith("'") && part.endsWith("'"))
) {
return <span key={idx} className="text-emerald-500">{part}</span>;
}
if (/^\d+(?:\.\d+)?[dDfFlL]?$/.test(part)) {
return <span key={idx} className="text-orange-400">{part}</span>;
}
return <span key={idx}>{part}</span>;
});
}
export function SourceViewer({ source, filename }: SourceViewerProps) {
const isJava = filename.endsWith(".java");
const highlighter = isJava ? highlightJavaLine : highlightPythonLine;
const lines = useMemo(() => source.split("\n"), [source]);
return (
@@ -90,7 +155,7 @@ export function SourceViewer({ source, filename }: SourceViewerProps) {
{i + 1}
</span>
<span className="text-zinc-200">
{highlightLine(line)}
{highlighter(line)}
</span>
</div>
))}
+1 -1
View File
@@ -122,7 +122,7 @@ export function WhatsNew({ diff }: WhatsNewProps) {
{td("loc_delta")}
</h3>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
+{diff.locDelta} lines
+{diff.locDelta} {td("lines")}
</p>
</div>
</Card>
+2 -2
View File
@@ -95,7 +95,7 @@ export function Header() {
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
href="https://github.com/abel533/learn-claude-code"
target="_blank"
rel="noopener"
className="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
@@ -151,7 +151,7 @@ export function Header() {
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
href="https://github.com/abel533/learn-claude-code"
target="_blank"
rel="noopener"
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
+1 -1
View File
@@ -16,7 +16,7 @@ const LAYER_DOT_BG: Record<string, string> = {
export function Sidebar() {
const pathname = usePathname();
const locale = pathname.split("/")[1] || "en";
const locale = pathname.split("/")[1] || "zh";
const t = useTranslations("sessions");
const tLayer = useTranslations("layer_labels");
+6 -4
View File
@@ -45,6 +45,8 @@ const MAX_LOC = Math.max(
export function Timeline() {
const t = useTranslations("timeline");
const tv = useTranslations("version");
const tMeta = useTranslations("version_meta");
const tLayer = useTranslations("layer_labels");
const locale = useLocale();
return (
@@ -60,7 +62,7 @@ export function Timeline() {
<span
className={cn("h-3 w-3 rounded-full", LAYER_DOT_BG[layer.id])}
/>
<span className="text-xs font-medium">{layer.label}</span>
<span className="text-xs font-medium">{tLayer(layer.id)}</span>
</div>
))}
</div>
@@ -114,14 +116,14 @@ export function Timeline() {
<div className="flex flex-wrap items-start gap-2">
<LayerBadge layer={meta.layer}>{versionId}</LayerBadge>
<span className="text-xs text-[var(--color-text-secondary)]">
{meta.coreAddition}
{tMeta(`${versionId}.coreAddition`)}
</span>
</div>
<h3 className="mt-2 text-base font-semibold sm:text-lg">
{meta.title}
<span className="ml-2 text-sm font-normal text-[var(--color-text-secondary)]">
{meta.subtitle}
{tMeta(`${versionId}.subtitle`)}
</span>
</h3>
@@ -149,7 +151,7 @@ export function Timeline() {
{/* Key insight */}
{meta.keyInsight && (
<p className="mt-3 text-sm italic text-[var(--color-text-secondary)]">
&ldquo;{meta.keyInsight}&rdquo;
&ldquo;{tMeta(`${versionId}.keyInsight`)}&rdquo;
</p>
)}
@@ -20,7 +20,7 @@ interface FlowNode {
const NODES: FlowNode[] = [
{ id: "start", label: "Start", x: 160, y: 30, w: 120, h: 40, type: "rect" },
{ id: "api_call", label: "API Call", x: 160, y: 110, w: 120, h: 40, type: "rect" },
{ id: "check", label: "stop_reason?", x: 160, y: 200, w: 140, h: 50, type: "diamond" },
{ id: "check", label: "Tool calls?", x: 160, y: 200, w: 140, h: 50, type: "diamond" },
{ id: "execute", label: "Execute Tool", x: 160, y: 300, w: 120, h: 40, type: "rect" },
{ id: "append", label: "Append Result", x: 160, y: 380, w: 120, h: 40, type: "rect" },
{ id: "end", label: "Break / Done", x: 380, y: 200, w: 120, h: 40, type: "rect" },
@@ -36,10 +36,10 @@ interface FlowEdge {
const EDGES: FlowEdge[] = [
{ from: "start", to: "api_call" },
{ from: "api_call", to: "check" },
{ from: "check", to: "execute", label: "tool_use" },
{ from: "check", to: "execute", label: "Yes" },
{ from: "execute", to: "append" },
{ from: "append", to: "api_call" },
{ from: "check", to: "end", label: "end_turn" },
{ from: "check", to: "end", label: "No" },
];
// Which nodes light up at each step
@@ -91,10 +91,10 @@ const STEP_INFO = [
{ title: "The While Loop", desc: "Every agent is a while loop that keeps calling the model until it says 'stop'." },
{ title: "User Input", desc: "The loop starts when the user sends a message." },
{ title: "Call the Model", desc: "Send all messages to the LLM. It sees everything and decides what to do." },
{ title: "stop_reason: tool_use", desc: "The model wants to use a tool. The loop continues." },
{ title: "Tool call detected", desc: "The model wants to use a tool. The loop continues." },
{ title: "Execute & Append", desc: "Run the tool, append the result to messages[]. Feed it back." },
{ title: "Loop Again", desc: "Same code path, second iteration. The model decides to edit a file." },
{ title: "stop_reason: end_turn", desc: "The model is done. Loop exits. That's the entire agent." },
{ title: "No tool calls → return text", desc: "The model is done. Loop exits. That's the entire agent." },
];
// -- Helpers --
@@ -171,7 +171,7 @@ export default function AgentLoop({ title }: { title?: string }) {
{/* Left panel: SVG Flowchart (60%) */}
<div className="w-full lg:w-[60%]">
<div className="mb-2 font-mono text-xs text-zinc-400 dark:text-zinc-500">
while (stop_reason === "tool_use")
chatClient.prompt().call().content()
</div>
<svg
viewBox="0 0 500 440"
@@ -66,12 +66,12 @@ const REQUEST_PER_STEP: (string | null)[] = [
// Step annotations
const STEP_INFO = [
{ title: "The Dispatch Map", desc: "A dictionary maps tool names to handler functions. The loop code never changes." },
{ title: "Route: bash", desc: "tool_call.name -> handlers['bash'](input). Name-based routing." },
{ title: "Route: read_file", desc: "Same pattern, different handler. Validate input, execute, return result." },
{ title: "Route: write_file", desc: "Every tool returns a tool_result that goes back into messages[]." },
{ title: "Route: edit_file", desc: "Adding a new tool = adding one entry to the dispatch map." },
{ title: "The Key Insight", desc: "The while loop stays the same. You only grow the dispatch map. That's it." },
{ title: "The @Tool Registry", desc: "@Tool annotated methods are registered via defaultTools(). The loop code never changes." },
{ title: "Route: bash", desc: "ChatClient detects tool_call → invokes @Tool method. Name-based routing." },
{ title: "Route: read_file", desc: "Same pattern, different @Tool method. Validate input, execute, return result." },
{ title: "Route: write_file", desc: "Every @Tool returns a result that Spring AI feeds back to the model." },
{ title: "Route: edit_file", desc: "Adding a new tool = adding one @Tool annotated class to defaultTools()." },
{ title: "The Key Insight", desc: "The call() loop stays the same. You only grow the @Tool registry. That's it." },
];
// SVG layout constants
@@ -46,7 +46,7 @@ const STEPS: StepState[] = [
],
worktrees: [],
lanes: [
{ name: "main", files: ["auth/service.py", "ui/Login.tsx"], highlight: true },
{ name: "main", files: ["auth/AuthService.java", "ui/Login.tsx"], highlight: true },
{ name: "wt/auth-refactor", files: [] },
{ name: "wt/ui-login", files: [] },
],
@@ -64,7 +64,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: ["ui/Login.tsx"] },
{ name: "wt/auth-refactor", files: ["auth/service.py"], highlight: true },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java"], highlight: true },
{ name: "wt/ui-login", files: [] },
],
},
@@ -82,7 +82,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: [] },
{ name: "wt/auth-refactor", files: ["auth/service.py"] },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java"] },
{ name: "wt/ui-login", files: ["ui/Login.tsx"], highlight: true },
],
},
@@ -100,7 +100,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: [] },
{ name: "wt/auth-refactor", files: ["auth/service.py", "tests/auth/test_login.py"], highlight: true },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java", "tests/auth/AuthServiceTest.java"], highlight: true },
{ name: "wt/ui-login", files: ["ui/Login.tsx", "ui/Login.css"] },
],
},
+13 -7
View File
@@ -8,25 +8,29 @@
"alternatives": "We could have started with a richer toolset (file I/O, HTTP, database), but that would obscure the core insight: an LLM with a shell is already a general-purpose agent. Starting minimal also makes it obvious what each subsequent version actually adds.",
"zh": {
"title": "为什么仅靠 Bash 就够了",
"description": "Bash 能读写文件、运行任意程序、在进程间传递数据、管理文件系统。任何额外的工具(read_file、write_file 等)都只是 bash 已有能力的子集。增加工具并不会解锁新能力,只会增加模型需要理解的接口。模型只需学习一个工具的 schema,实现代码不超过 100 行。这就是最小可行 agent:一个工具,一个循环。"
"description": "Bash 能读写文件、运行任意程序、在进程间传递数据、管理文件系统。任何额外的工具(read_file、write_file 等)都只是 bash 已有能力的子集。增加工具并不会解锁新能力,只会增加模型需要理解的接口。模型只需学习一个工具的 schema,实现代码不超过 100 行。这就是最小可行 agent:一个工具,一个循环。",
"alternatives": "我们可以从更丰富的工具集开始(文件 I/O、HTTP、数据库),但那会掩盖核心洞察:一个配备 shell 的 LLM 已经是一个通用 agent。从最小化开始,也让每个后续版本真正新增了什么变得一目了然。"
},
"ja": {
"title": "Bash だけで十分な理由",
"description": "Bash はファイルの読み書き、任意のプログラムの実行、プロセス間のデータパイプ、ファイルシステムの管理が可能です。追加のツール(read_file、write_file など)は bash が既に提供している機能の部分集合に過ぎません。ツールを増やしても新しい能力は得られず、モデルが理解すべきインターフェースが増えるだけです。モデルが学習するスキーマは1つだけで、実装は100行以内に収まります。これが最小限の実用的エージェント:1つのツール、1つのループです。"
"description": "Bash はファイルの読み書き、任意のプログラムの実行、プロセス間のデータパイプ、ファイルシステムの管理が可能です。追加のツール(read_file、write_file など)は bash が既に提供している機能の部分集合に過ぎません。ツールを増やしても新しい能力は得られず、モデルが理解すべきインターフェースが増えるだけです。モデルが学習するスキーマは1つだけで、実装は100行以内に収まります。これが最小限の実用的エージェント:1つのツール、1つのループです。",
"alternatives": "より豊富なツールセット(ファイル I/O、HTTP、データベース)から始めることもできましたが、それではコアの洞察が曖昧になります:シェルを持つ LLM は既に汎用エージェントです。最小限から始めることで、後続の各バージョンが実際に何を追加しているかが明確になります。"
}
},
{
"id": "process-as-subagent",
"title": "Recursive Process Spawning as Subagent Mechanism",
"description": "When the agent runs `python v0.py \"subtask\"`, it spawns a completely new process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just Unix process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"description": "When the agent runs `java -jar agent.jar \"subtask\"`, it uses ProcessBuilder to spawn a completely new JVM process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"alternatives": "A framework-level subagent system (like v3's Task tool) gives more control over what tools the subagent can access and how results are returned. But at v0, the point is to show that process spawning is the most primitive form of agent delegation -- no shared memory, no message passing, just stdin/stdout.",
"zh": {
"title": "用递归进程创建实现子代理机制",
"description": "当 agent 执行 `python v0.py \"subtask\"` 时,它会创建一个全新的进程,拥有全新的 LLM 上下文。这个子进程实际上就是一个子代理:有自己的系统提示词、对话历史和任务焦点。子进程完成后,父进程通过 stdout 获取结果。这就是不依赖任何框架的子代理委派——纯粹的 Unix 进程语义。每个子进程天然隔离关注点,因为它根本看不到父进程的上下文。"
"description": "当 agent 执行 `java -jar agent.jar \"subtask\"` 时,它通过 ProcessBuilder 创建一个全新的 JVM 进程,拥有全新的 LLM 上下文。这个子进程实际上就是一个子代理:有自己的系统提示词、对话历史和任务焦点。子进程完成后,父进程通过 stdout 获取结果。这就是不依赖任何框架的子代理委派——纯粹的进程语义。每个子进程天然隔离关注点,因为它根本看不到父进程的上下文。",
"alternatives": "框架级的子代理系统(如 v3 的 Task 工具)可以更精确地控制子代理能访问哪些工具以及结果如何返回。但在 v0 阶段,关键是展示进程创建是最原始的代理委派形式——没有共享内存,没有消息传递,只有 stdin/stdout。"
},
"ja": {
"title": "再帰プロセス生成によるサブエージェント機構",
"description": "エージェントが `python v0.py \"subtask\"` を実行すると、新しい LLM コンテキストを持つ完全に新しいプロセスが生成されます。この子プロセスは事実上サブエージェントです:独自のシステムプロンプト、会話履歴、タスクフォーカスを持ちます。完了すると、親プロセスは stdout で結果を受け取ります。これはフレームワークなしのサブエージェント委任です——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。各子プロセスは親のコンテキストを参照できないため、関心の分離が自然に実現されます。"
"description": "エージェントが `java -jar agent.jar \"subtask\"` を実行すると、ProcessBuilder により新しい LLM コンテキストを持つ完全に新しい JVM プロセスが生成されます。この子プロセスは事実上サブエージェントです:独自のシステムプロンプト、会話履歴、タスクフォーカスを持ちます。完了すると、親プロセスは stdout で結果を受け取ります。これはフレームワークなしのサブエージェント委任です——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。各子プロセスは親のコンテキストを参照できないため、関心の分離が自然に実現されます。",
"alternatives": "フレームワークレベルのサブエージェントシステム(v3 の Task ツールなど)は、サブエージェントがアクセスできるツールや結果の返し方をより細かく制御できます。しかし v0 の段階では、プロセス生成がエージェント委任の最も原始的な形態であることを示すのがポイントです——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Later versions (v2) add explicit planning via TodoWrite. But v0 proves that implicit planning through the model's reasoning is sufficient for many tasks. The planning framework only becomes necessary when you need external visibility into the agent's intentions.",
"zh": {
"title": "没有规划框架——由模型自行决策",
"description": "没有规划器,没有任务队列,没有状态机。系统提示词告诉模型如何处理问题,模型根据对话历史决定下一步执行什么 bash 命令。这是有意为之的:在这个层级,添加规划层属于过早抽象。模型的思维链本身就是计划。agent 循环只是不断询问模型下一步做什么,直到模型不再请求工具为止。"
"description": "没有规划器,没有任务队列,没有状态机。系统提示词告诉模型如何处理问题,模型根据对话历史决定下一步执行什么 bash 命令。这是有意为之的:在这个层级,添加规划层属于过早抽象。模型的思维链本身就是计划。agent 循环只是不断询问模型下一步做什么,直到模型不再请求工具为止。",
"alternatives": "后续版本(v2)通过 TodoWrite 添加了显式规划。但 v0 证明了通过模型推理进行的隐式规划对许多任务已经足够。只有当你需要从外部观察 agent 的意图时,规划框架才变得必要。"
},
"ja": {
"title": "計画フレームワークなし——モデルが全てを決定",
"description": "プランナーもタスクキューも状態マシンもありません。システムプロンプトがモデルに問題の取り組み方を伝え、モデルがこれまでの会話に基づいて次に実行する bash コマンドを決定します。これは意図的な設計です:このレベルでは計画レイヤーの追加は時期尚早な抽象化です。モデルの思考の連鎖そのものが計画です。エージェントループはモデルがツールの呼び出しを止めるまで、次の行動を問い続けるだけです。"
"description": "プランナーもタスクキューも状態マシンもありません。システムプロンプトがモデルに問題の取り組み方を伝え、モデルがこれまでの会話に基づいて次に実行する bash コマンドを決定します。これは意図的な設計です:このレベルでは計画レイヤーの追加は時期尚早な抽象化です。モデルの思考の連鎖そのものが計画です。エージェントループはモデルがツールの呼び出しを止めるまで、次の行動を問い続けるだけです。",
"alternatives": "後のバージョン(v2)では TodoWrite による明示的な計画が追加されます。しかし v0 は、モデルの推論による暗黙的な計画が多くのタスクに十分であることを証明しています。計画フレームワークが必要になるのは、エージェントの意図を外部から可視化する必要がある場合だけです。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "We could add specialized tools (list_directory, search_files, http_request), and later versions do. But at this stage, bash already covers those use cases. The split from v0's single tool to v1's four tools is specifically about giving the model structured I/O for file operations, where bash's quoting and escaping often trips up the model.",
"zh": {
"title": "为什么恰好四个工具",
"description": "四个工具分别是 bash、read_file、write_file 和 edit_file,覆盖了大约 95% 的编程任务。Bash 处理执行和任意命令;read_file 提供带行号的精确文件读取;write_file 创建或覆盖文件;edit_file 做精确的字符串替换。工具越多,模型的认知负担越重——它必须在更多选项中做选择,选错的概率也随之增加。更少的工具也意味着更少的 schema 需要维护、更少的边界情况需要处理。"
"description": "四个工具分别是 bash、read_file、write_file 和 edit_file,覆盖了大约 95% 的编程任务。Bash 处理执行和任意命令;read_file 提供带行号的精确文件读取;write_file 创建或覆盖文件;edit_file 做精确的字符串替换。工具越多,模型的认知负担越重——它必须在更多选项中做选择,选错的概率也随之增加。更少的工具也意味着更少的 schema 需要维护、更少的边界情况需要处理。",
"alternatives": "我们可以添加专用工具(list_directory、search_files、http_request),后续版本确实这么做了。但在当前阶段,bash 已经覆盖了这些用例。从 v0 的单一工具拆分为 v1 的四个工具,专门是为了给模型提供结构化的文件 I/O 操作,因为 bash 的引号和转义经常让模型出错。"
},
"ja": {
"title": "なぜ正確に4つのツールなのか",
"description": "4つのツールは bash、read_file、write_file、edit_file です。これらでコーディングタスクの約95%をカバーします。Bash は実行と任意のコマンドを処理し、read_file は行番号付きの正確なファイル読み取りを提供し、write_file はファイルの作成・上書きを行い、edit_file は外科的な文字列置換を行います。ツールが増えるとモデルの認知負荷が増大し、どのツールを使うかの判断でミスが増えます。ツールが少ないことは、メンテナンスすべきスキーマとエッジケースの削減も意味します。"
"description": "4つのツールは bash、read_file、write_file、edit_file です。これらでコーディングタスクの約95%をカバーします。Bash は実行と任意のコマンドを処理し、read_file は行番号付きの正確なファイル読み取りを提供し、write_file はファイルの作成・上書きを行い、edit_file は外科的な文字列置換を行います。ツールが増えるとモデルの認知負荷が増大し、どのツールを使うかの判断でミスが増えます。ツールが少ないことは、メンテナンスすべきスキーマとエッジケースの削減も意味します。",
"alternatives": "専用ツール(list_directory、search_files、http_request)を追加することもでき、後のバージョンではそうしています。しかしこの段階では bash が既にそれらのユースケースをカバーしています。v0 の単一ツールから v1 の4つのツールへの分割は、特にファイル操作に構造化された I/O を提供するためです。bash のクォーティングとエスケープはモデルを頻繁につまずかせるからです。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Many agent frameworks add elaborate orchestration layers: ReAct loops with explicit Thought/Action/Observation parsing, LangChain-style chains, AutoGPT-style goal decomposition. These frameworks assume the model needs scaffolding to behave as an agent. Our approach assumes the model already knows how to be an agent -- it just needs tools to act on the world.",
"zh": {
"title": "模型本身就是代理",
"description": "核心 agent 循环极其简单:不断调用 LLM,如果返回 tool_use 块就执行并回传结果,如果只返回文本就停止。没有路由器,没有决策树,没有工作流引擎。模型自己决定做什么、何时停止、如何从错误中恢复。代码只是连接模型和工具的管道。这是一种设计哲学:agent 行为从模型中涌现,而非由框架定义。"
"description": "核心 agent 循环极其简单:不断调用 LLM,如果返回 tool_use 块就执行并回传结果,如果只返回文本就停止。没有路由器,没有决策树,没有工作流引擎。模型自己决定做什么、何时停止、如何从错误中恢复。代码只是连接模型和工具的管道。这是一种设计哲学:agent 行为从模型中涌现,而非由框架定义。",
"alternatives": "许多 agent 框架添加了复杂的编排层:带有显式 Thought/Action/Observation 解析的 ReAct 循环、LangChain 风格的链、AutoGPT 风格的目标分解。这些框架假设模型需要脚手架才能作为 agent 运行。我们的方法假设模型已经知道如何成为 agent——它只需要工具来作用于世界。"
},
"ja": {
"title": "モデルそのものがエージェント",
"description": "コアのエージェントループは極めてシンプルです:LLM を呼び出し続け、tool_use ブロックが返されればそれを実行して結果をフィードバックし、テキストのみが返されれば停止します。ルーターも決定木もワークフローエンジンもありません。モデル自体が何をすべきか、いつ停止するか、エラーからどう回復するかを決定します。コードはモデルとツールを接続する配管に過ぎません。これは設計思想です:エージェントの振る舞いはフレームワークではなくモデルから創発するものです。"
"description": "コアのエージェントループは極めてシンプルです:LLM を呼び出し続け、tool_use ブロックが返されればそれを実行して結果をフィードバックし、テキストのみが返されれば停止します。ルーターも決定木もワークフローエンジンもありません。モデル自体が何をすべきか、いつ停止するか、エラーからどう回復するかを決定します。コードはモデルとツールを接続する配管に過ぎません。これは設計思想です:エージェントの振る舞いはフレームワークではなくモデルから創発するものです。",
"alternatives": "多くのエージェントフレームワークは精巧なオーケストレーション層を追加します:明示的な Thought/Action/Observation パースを持つ ReAct ループ、LangChain スタイルのチェーン、AutoGPT スタイルの目標分解。これらのフレームワークはモデルがエージェントとして振る舞うためにスキャフォールディングが必要だと仮定しています。我々のアプローチはモデルが既にエージェントの振る舞い方を知っていると仮定します——必要なのは世界に作用するためのツールだけです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Some agent systems let the model output free-form text that gets parsed with regex or heuristics (e.g., extracting code from markdown blocks). This is fragile -- the model might format output slightly differently and break the parser. JSON schemas trade flexibility for reliability.",
"zh": {
"title": "每个工具都有 JSON Schema",
"description": "每个工具都为输入参数定义了严格的 JSON schema。例如,edit_file 要求 old_string 和 new_string 是精确的字符串,而非正则表达式。这消除了一整类错误:模型无法传递格式错误的输入,因为 API 会在执行前校验 schema。这也使模型的意图变得明确——当它用特定字符串调用 edit_file 时,不存在关于它想修改什么的解析歧义。"
"description": "每个工具都为输入参数定义了严格的 JSON schema。例如,edit_file 要求 old_string 和 new_string 是精确的字符串,而非正则表达式。这消除了一整类错误:模型无法传递格式错误的输入,因为 API 会在执行前校验 schema。这也使模型的意图变得明确——当它用特定字符串调用 edit_file 时,不存在关于它想修改什么的解析歧义。",
"alternatives": "一些 agent 系统让模型输出自由格式的文本,然后用正则表达式或启发式方法解析(例如从 markdown 块中提取代码)。这很脆弱——模型可能稍微改变输出格式就会破坏解析器。JSON Schema 用灵活性换取了可靠性。"
},
"ja": {
"title": "全ツールに JSON Schema を定義",
"description": "各ツールは入力パラメータに対して厳密な JSON Schema を定義しています。例えば edit_file は old_string と new_string を正確な文字列として要求し、正規表現は使いません。これにより一連のバグを排除できます:API がスキーマに対して実行前にバリデーションを行うため、モデルは不正な入力を渡せません。モデルの意図も明確になります――特定の文字列で edit_file を呼び出す際、何を変更したいかについて解析の曖昧さがありません。"
"description": "各ツールは入力パラメータに対して厳密な JSON Schema を定義しています。例えば edit_file は old_string と new_string を正確な文字列として要求し、正規表現は使いません。これにより一連のバグを排除できます:API がスキーマに対して実行前にバリデーションを行うため、モデルは不正な入力を渡せません。モデルの意図も明確になります――特定の文字列で edit_file を呼び出す際、何を変更したいかについて解析の曖昧さがありません。",
"alternatives": "一部のエージェントシステムはモデルに自由形式のテキストを出力させ、正規表現やヒューリスティクスで解析します(例:markdown ブロックからコードを抽出)。これは脆弱です——モデルが出力を微妙に異なるフォーマットにするとパーサーが壊れます。JSON Schema は柔軟性を犠牲にして信頼性を得ています。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "The model could plan internally via chain-of-thought reasoning (as it does in v0/v1). Internal planning works but is invisible and ephemeral -- once the thinking scrolls out of context, the plan is lost. Claude's extended thinking is another option, but it's not inspectable by the user or by downstream tools.",
"zh": {
"title": "通过 TodoWrite 让计划可见",
"description": "我们不让模型在思维链中默默规划,而是强制通过 TodoWrite 工具将计划外化。每个计划项都有可追踪的状态(pending、in_progress、completed)。这有三个好处:(1) 用户可以在执行前看到 agent 打算做什么;(2) 开发者可以通过检查计划状态来调试 agent 行为;(3) agent 自身可以在后续轮次中引用计划,即使早期上下文已经滚出窗口。"
"description": "我们不让模型在思维链中默默规划,而是强制通过 TodoWrite 工具将计划外化。每个计划项都有可追踪的状态(pending、in_progress、completed)。这有三个好处:(1) 用户可以在执行前看到 agent 打算做什么;(2) 开发者可以通过检查计划状态来调试 agent 行为;(3) agent 自身可以在后续轮次中引用计划,即使早期上下文已经滚出窗口。",
"alternatives": "模型可以通过思维链推理进行内部规划(如 v0/v1 中那样)。内部规划可行,但不可见且转瞬即逝——一旦思考内容滚出上下文窗口,计划就丢失了。Claude 的扩展思考是另一个选项,但用户和下游工具无法检查它。"
},
"ja": {
"title": "TodoWrite による計画の可視化",
"description": "モデルが思考の連鎖の中で黙って計画するのではなく、TodoWrite ツールを通じて計画を外部化することを強制します。各計画項目には追跡可能なステータス(pending、in_progress、completed)があります。利点は3つ:(1) ユーザーがエージェントの意図を実行前に確認できる、(2) 開発者が計画状態を検査してデバッグできる、(3) エージェント自身が以前のコンテキストがスクロールアウトした後でも計画を参照できる。"
"description": "モデルが思考の連鎖の中で黙って計画するのではなく、TodoWrite ツールを通じて計画を外部化することを強制します。各計画項目には追跡可能なステータス(pending、in_progress、completed)があります。利点は3つ:(1) ユーザーがエージェントの意図を実行前に確認できる、(2) 開発者が計画状態を検査してデバッグできる、(3) エージェント自身が以前のコンテキストがスクロールアウトした後でも計画を参照できる。",
"alternatives": "モデルは v0/v1 のように思考の連鎖による内部計画が可能です。内部計画は機能しますが、不可視で一時的です——思考がコンテキストからスクロールアウトすると計画は失われます。Claude の拡張思考は別の選択肢ですが、ユーザーや下流ツールからは検査できません。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Allowing multiple in-progress items would let the agent context-switch between tasks, which seems more flexible. In practice, LLMs handle context-switching poorly -- they lose track of which task they were working on and mix up details between tasks. The single-focus constraint is a guardrail that improves output quality.",
"zh": {
"title": "同一时间只允许一个任务进行中",
"description": "TodoWrite 工具强制要求任何时候最多只能有一个任务处于 in_progress 状态。如果模型想开始第二个任务,必须先完成或放弃当前任务。这个约束防止了一种隐蔽的失败模式:试图通过交替处理多个项目来'多任务'的模型,往往会丢失状态并产出半成品。顺序执行的专注度远高于并行切换。"
"description": "TodoWrite 工具强制要求任何时候最多只能有一个任务处于 in_progress 状态。如果模型想开始第二个任务,必须先完成或放弃当前任务。这个约束防止了一种隐蔽的失败模式:试图通过交替处理多个项目来'多任务'的模型,往往会丢失状态并产出半成品。顺序执行的专注度远高于并行切换。",
"alternatives": "允许多个进行中的项目可以让 agent 在任务间切换,看似更灵活。但实际上,LLM 处理上下文切换很差——它们会忘记自己在处理哪个任务,并混淆不同任务间的细节。单一焦点约束是一个提升输出质量的护栏。"
},
"ja": {
"title": "同時に進行中にできるタスクは1つだけ",
"description": "TodoWrite ツールは、同時に 'in_progress' 状態のタスクを最大1つに制限します。モデルが2つ目のタスクを開始しようとする場合、まず現在のタスクを完了または中断する必要があります。この制約は微妙な失敗モードを防ぎます:複数の項目を交互に処理して「マルチタスク」しようとするモデルは、状態を見失い中途半端な結果を生みがちです。逐次的な集中は並行的な切り替えよりも高品質な出力を生み出します。"
"description": "TodoWrite ツールは、同時に 'in_progress' 状態のタスクを最大1つに制限します。モデルが2つ目のタスクを開始しようとする場合、まず現在のタスクを完了または中断する必要があります。この制約は微妙な失敗モードを防ぎます:複数の項目を交互に処理して「マルチタスク」しようとするモデルは、状態を見失い中途半端な結果を生みがちです。逐次的な集中は並行的な切り替えよりも高品質な出力を生み出します。",
"alternatives": "複数の進行中項目を許可すればエージェントがタスク間を切り替えられ、より柔軟に見えます。しかし実際には、LLM はコンテキストスイッチングを苦手とし、どのタスクに取り組んでいたかを見失い、タスク間の詳細を混同します。単一フォーカスの制約は出力品質を向上させるガードレールです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "No cap would give the model full flexibility, but in practice leads to absurdly detailed plans. A dynamic cap (proportional to task complexity) would be smarter but adds complexity. The fixed cap of 20 is a simple heuristic that works well empirically -- most real coding tasks can be expressed in 5-15 meaningful steps.",
"zh": {
"title": "计划项上限为 20 条",
"description": "TodoWrite 将计划项限制在 20 条以内。这是对过度规划的刻意约束。不加限制时,模型倾向于将任务分解成越来越细粒度的步骤,产出 50 条的计划,每一步都微不足道。冗长的计划很脆弱:如果第 15 步失败,剩下的 35 步可能全部作废。20 条以内的短计划保持在正确的抽象层级,更容易在现实偏离计划时做出调整。"
"description": "TodoWrite 将计划项限制在 20 条以内。这是对过度规划的刻意约束。不加限制时,模型倾向于将任务分解成越来越细粒度的步骤,产出 50 条的计划,每一步都微不足道。冗长的计划很脆弱:如果第 15 步失败,剩下的 35 步可能全部作废。20 条以内的短计划保持在正确的抽象层级,更容易在现实偏离计划时做出调整。",
"alternatives": "不设上限可以给模型完全的灵活性,但实际上会导致荒谬的详细计划。动态上限(与任务复杂度成比例)更聪明但增加了复杂性。固定的 20 条上限是一个简单的启发式规则,在实践中效果很好——大多数真实编程任务可以用 5-15 个有意义的步骤来表达。"
},
"ja": {
"title": "計画項目の上限は20個",
"description": "TodoWrite は計画を20項目に制限します。これは過度な計画に対する意図的な制約です。制約がないとモデルはタスクをどんどん細かいステップに分解し、各ステップが些末な50項目の計画を作りがちです。長い計画は脆弱です:ステップ15が失敗すると残りの35ステップは全て無効になりかねません。20項目以内の短い計画は適切な抽象度を保ち、現実が計画から逸脱した際の適応が容易です。"
"description": "TodoWrite は計画を20項目に制限します。これは過度な計画に対する意図的な制約です。制約がないとモデルはタスクをどんどん細かいステップに分解し、各ステップが些末な50項目の計画を作りがちです。長い計画は脆弱です:ステップ15が失敗すると残りの35ステップは全て無効になりかねません。20項目以内の短い計画は適切な抽象度を保ち、現実が計画から逸脱した際の適応が容易です。",
"alternatives": "上限なしはモデルに完全な柔軟性を与えますが、実際には非現実的に詳細な計画につながります。動的な上限(タスクの複雑さに比例)はより賢明ですが複雑さが増します。20の固定上限はシンプルなヒューリスティクスで、経験的にうまく機能します——ほとんどの実際のコーディングタスクは5〜15の意味あるステップで表現できます。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "Sharing the parent's full context would give the subagent more information, but it would also flood the subagent with irrelevant details. Context window is finite -- filling it with parent history leaves less room for the subagent's own work. Fork-based approaches (copy the parent context) are a middle ground but still waste tokens on irrelevant history.",
"zh": {
"title": "子代理获得全新上下文,而非共享历史",
"description": "当父代理通过 Task 工具创建子代理时,子代理从全新的消息历史开始,只包含系统提示词和委派的任务描述,不继承父代理的对话。这就是上下文隔离:子代理可以完全专注于特定子任务,不会被父代理长达数百条消息的对话干扰。结果作为单条 tool_result 返回给父代理,将子代理可能数十轮的交互压缩为一个简洁的回答。"
"description": "当父代理通过 Task 工具创建子代理时,子代理从全新的消息历史开始,只包含系统提示词和委派的任务描述,不继承父代理的对话。这就是上下文隔离:子代理可以完全专注于特定子任务,不会被父代理长达数百条消息的对话干扰。结果作为单条 tool_result 返回给父代理,将子代理可能数十轮的交互压缩为一个简洁的回答。",
"alternatives": "共享父代理的完整上下文可以给子代理更多信息,但也会用无关细节淹没子代理。上下文窗口是有限的——用父代理的历史填满它,留给子代理自身工作的空间就更少了。基于分叉的方法(复制父上下文)是折中方案,但仍然在无关历史上浪费 token。"
},
"ja": {
"title": "サブエージェントは共有履歴ではなく新しいコンテキストを取得",
"description": "親エージェントが Task ツールでサブエージェントを生成すると、サブエージェントはシステムプロンプトと委任されたタスク説明のみを含むクリーンなメッセージ履歴から開始します。親の会話は引き継ぎません。これがコンテキスト分離です:サブエージェントは親の広範な会話の何百ものメッセージに気を取られることなく、特定のサブタスクに完全に集中できます。結果は単一の tool_result として親に返され、サブエージェントの数十ターンが1つの簡潔な回答に凝縮されます。"
"description": "親エージェントが Task ツールでサブエージェントを生成すると、サブエージェントはシステムプロンプトと委任されたタスク説明のみを含むクリーンなメッセージ履歴から開始します。親の会話は引き継ぎません。これがコンテキスト分離です:サブエージェントは親の広範な会話の何百ものメッセージに気を取られることなく、特定のサブタスクに完全に集中できます。結果は単一の tool_result として親に返され、サブエージェントの数十ターンが1つの簡潔な回答に凝縮されます。",
"alternatives": "親の完全なコンテキストを共有すればサブエージェントにより多くの情報を与えられますが、無関係な詳細でサブエージェントを溢れさせることにもなります。コンテキストウィンドウは有限です——親の履歴で埋めるとサブエージェント自身の作業に使える余地が少なくなります。フォークベースのアプローチ(親コンテキストをコピー)は中間的な手段ですが、無関係な履歴にトークンを浪費します。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Giving all subagents full tool access is simpler to implement but violates least privilege. A permission-request system (subagent asks parent for write access) adds complexity and latency. Static tool filtering by role is the pragmatic middle ground -- simple to implement, effective at preventing accidents.",
"zh": {
"title": "Explore 代理不能写入文件",
"description": "创建 Explore 类型的子代理时,它只获得只读工具:bash(有限制)、read_file 和搜索工具,不能调用 write_file 或 edit_file。这实现了最小权限原则:一个被委派'查找函数 X 所有使用位置'的代理不需要写权限。移除写工具消除了探索过程中误修改文件的风险,同时缩小了工具空间,让模型在更少的选项中做出更好的决策。"
"description": "创建 Explore 类型的子代理时,它只获得只读工具:bash(有限制)、read_file 和搜索工具,不能调用 write_file 或 edit_file。这实现了最小权限原则:一个被委派'查找函数 X 所有使用位置'的代理不需要写权限。移除写工具消除了探索过程中误修改文件的风险,同时缩小了工具空间,让模型在更少的选项中做出更好的决策。",
"alternatives": "给所有子代理完整的工具访问权限实现更简单,但违反了最小权限原则。权限请求系统(子代理向父代理请求写权限)增加了复杂性和延迟。基于角色的静态工具过滤是务实的折中方案——实现简单,有效防止意外操作。"
},
"ja": {
"title": "Explore エージェントはファイルを書き込めない",
"description": "Explore タイプのサブエージェントを生成すると、読み取り専用ツールのみが提供されます:bash(制限付き)、read_file、検索ツール。write_file や edit_file は使えません。これは最小権限の原則の実装です:「関数 X の全使用箇所を見つける」タスクに書き込み権限は不要です。書き込みツールを除外することで探索中の誤ったファイル変更リスクを排除し、ツール空間を狭めてモデルがより良い判断を下せるようにします。"
"description": "Explore タイプのサブエージェントを生成すると、読み取り専用ツールのみが提供されます:bash(制限付き)、read_file、検索ツール。write_file や edit_file は使えません。これは最小権限の原則の実装です:「関数 X の全使用箇所を見つける」タスクに書き込み権限は不要です。書き込みツールを除外することで探索中の誤ったファイル変更リスクを排除し、ツール空間を狭めてモデルがより良い判断を下せるようにします。",
"alternatives": "全サブエージェントに全ツールアクセスを与える方が実装は簡単ですが、最小権限の原則に違反します。権限要求システム(サブエージェントが親に書き込みアクセスを要求)は複雑さとレイテンシを増します。ロールに基づく静的なツールフィルタリングは実用的な中間策です——実装が簡単で、事故の防止に効果的です。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Allowing recursive delegation (bounded by depth) would handle deeply nested tasks but adds complexity and the risk of runaway token consumption. In practice, single-level delegation covers most real-world coding tasks. Multi-level delegation is addressed in later versions (v6+) through persistent team structures instead of recursive spawning.",
"zh": {
"title": "子代理不能再创建子代理",
"description": "Task 工具不包含在子代理的工具集中。子代理必须直接完成工作,不能继续委派。这防止了无限委派循环:没有这个约束,一个代理可能创建子代理,子代理又创建子代理,每一层都用略微不同的措辞重新委派同一任务,消耗 token 却毫无进展。一层委派足以处理绝大多数场景。如果任务对单个子代理来说太复杂,应该由父代理重新分解。"
"description": "Task 工具不包含在子代理的工具集中。子代理必须直接完成工作,不能继续委派。这防止了无限委派循环:没有这个约束,一个代理可能创建子代理,子代理又创建子代理,每一层都用略微不同的措辞重新委派同一任务,消耗 token 却毫无进展。一层委派足以处理绝大多数场景。如果任务对单个子代理来说太复杂,应该由父代理重新分解。",
"alternatives": "允许递归委派(以深度为界)可以处理深度嵌套的任务,但增加了复杂性和 token 消耗失控的风险。实际上,单层委派覆盖了大多数真实编程任务。多层委派在后续版本(v6+)中通过持久化的团队结构而非递归创建来解决。"
},
"ja": {
"title": "サブエージェントは自身のサブエージェントを生成できない",
"description": "Task ツールはサブエージェントのツールセットに含まれません。サブエージェントは作業を直接完了しなければならず、さらなる委任はできません。これにより無限委任ループを防止します:この制約がなければ、エージェントがサブエージェントを生成し、そのサブエージェントがさらにサブエージェントを生成し、それぞれが微妙に異なる言葉で同じタスクを再委任してトークンを消費するだけで進捗しない可能性があります。一段階の委任で大多数のユースケースに対応できます。"
"description": "Task ツールはサブエージェントのツールセットに含まれません。サブエージェントは作業を直接完了しなければならず、さらなる委任はできません。これにより無限委任ループを防止します:この制約がなければ、エージェントがサブエージェントを生成し、そのサブエージェントがさらにサブエージェントを生成し、それぞれが微妙に異なる言葉で同じタスクを再委任してトークンを消費するだけで進捗しない可能性があります。一段階の委任で大多数のユースケースに対応できます。",
"alternatives": "再帰的な委任(深さの上限付き)を許可すれば深くネストされたタスクに対応できますが、複雑さとトークン消費の暴走リスクが増します。実際には、一段階の委任でほとんどの実際のコーディングタスクをカバーできます。多段階委任は後のバージョン(v6+)で再帰的な生成ではなく永続的なチーム構造によって対処されます。"
}
}
]
+13 -7
View File
@@ -8,25 +8,29 @@
"alternatives": "Injecting skills into the system prompt is simpler and gives skills higher priority in the model's attention. But it breaks prompt caching (every skill load creates a new system prompt variant) and bloats the system prompt over time as skills accumulate. The tool_result approach keeps things cache-friendly at the cost of slightly lower attention priority.",
"zh": {
"title": "技能通过 tool_result 注入,而非系统提示词",
"description": "当 agent 调用 Skill 工具时,技能内容(SKILL.md 文件)作为 tool_result 在用户消息中返回,而非注入系统提示词。这是一个刻意的缓存优化:系统提示词在各轮次间保持静态,API 提供商可以缓存它(Anthropic 的 prompt caching、OpenAI 的 system message caching)。如果技能内容在系统提示词中,每次加载新技能都会使缓存失效。将动态内容放在 tool_result 中,既保持了昂贵的系统提示词可缓存,又让技能知识进入了上下文。"
"description": "当 agent 调用 Skill 工具时,技能内容(SKILL.md 文件)作为 tool_result 在用户消息中返回,而非注入系统提示词。这是一个刻意的缓存优化:系统提示词在各轮次间保持静态,API 提供商可以缓存它(Anthropic 的 prompt caching、OpenAI 的 system message caching)。如果技能内容在系统提示词中,每次加载新技能都会使缓存失效。将动态内容放在 tool_result 中,既保持了昂贵的系统提示词可缓存,又让技能知识进入了上下文。",
"alternatives": "将技能注入系统提示词更简单,能给予技能更高的模型注意力优先级。但它会破坏 prompt 缓存(每次加载技能都会创建新的系统提示词变体),且随着技能累积,系统提示词会不断膨胀。tool_result 方式以略低的注意力优先级为代价,保持了缓存友好性。"
},
"ja": {
"title": "スキルはシステムプロンプトではなく tool_result で注入",
"description": "エージェントが Skill ツールを呼び出すと、スキルの内容(SKILL.md ファイル)はシステムプロンプトへの注入ではなく、ユーザーメッセージ内の tool_result として返されます。これは意図的なキャッシュ最適化です:システムプロンプトはターン間で静的に保たれるため、API プロバイダーがキャッシュできます(Anthropic のプロンプトキャッシュ、OpenAI のシステムメッセージキャッシュ)。スキル内容がシステムプロンプト内にあると、新しいスキルをロードするたびにキャッシュが無効化されます。動的コンテンツを tool_result に配置することで、高コストなシステムプロンプトのキャッシュ可能性を維持しつつ、スキル知識をコンテキストに取り込めます。"
"description": "エージェントが Skill ツールを呼び出すと、スキルの内容(SKILL.md ファイル)はシステムプロンプトへの注入ではなく、ユーザーメッセージ内の tool_result として返されます。これは意図的なキャッシュ最適化です:システムプロンプトはターン間で静的に保たれるため、API プロバイダーがキャッシュできます(Anthropic のプロンプトキャッシュ、OpenAI のシステムメッセージキャッシュ)。スキル内容がシステムプロンプト内にあると、新しいスキルをロードするたびにキャッシュが無効化されます。動的コンテンツを tool_result に配置することで、高コストなシステムプロンプトのキャッシュ可能性を維持しつつ、スキル知識をコンテキストに取り込めます。",
"alternatives": "スキルをシステムプロンプトに注入する方がシンプルで、モデルの注意においてスキルに高い優先度を与えます。しかしプロンプトキャッシュが壊れ(スキルをロードするたびに新しいシステムプロンプトバリアントが作られ)、スキルが蓄積するにつれシステムプロンプトが肥大化します。tool_result アプローチはわずかに低い注意優先度と引き換えに、キャッシュフレンドリーを維持します。"
}
},
{
"id": "lazy-loading",
"title": "On-Demand Skill Loading Instead of Upfront",
"description": "Skills are not loaded at startup. The agent starts with only the skill names and descriptions (from frontmatter). When the agent decides it needs a specific skill, it calls the Skill tool, which loads the full SKILL.md body into context. This keeps the initial prompt small and focused. An agent solving a Python bug doesn't need the Kubernetes deployment skill loaded -- that would waste context window space and potentially confuse the model with irrelevant instructions.",
"description": "Skills are not loaded at startup. The agent starts with only the skill names and descriptions (from frontmatter). When the agent decides it needs a specific skill, it calls the Skill tool, which loads the full SKILL.md body into context. This keeps the initial prompt small and focused. An agent solving a Spring Boot bug doesn't need the Kubernetes deployment skill loaded -- that would waste context window space and potentially confuse the model with irrelevant instructions.",
"alternatives": "Loading all skills upfront guarantees the model always has all knowledge available, but wastes tokens on irrelevant skills and may hit context limits. A recommendation system (model suggests skills, human approves) adds latency. Lazy loading lets the model self-serve the knowledge it needs, when it needs it.",
"zh": {
"title": "按需加载技能而非预加载",
"description": "技能不会在启动时加载。Agent 初始只拥有技能名称和描述(来自 frontmatter)。当 agent 判断需要特定技能时,调用 Skill 工具将完整的 SKILL.md 内容加载到上下文中。这保持了初始提示词的精简。一个正在修复 Python bug 的 agent 不需要加载 Kubernetes 部署技能——那会浪费上下文窗口空间,还可能用无关指令干扰模型。"
"description": "技能不会在启动时加载。Agent 初始只拥有技能名称和描述(来自 frontmatter)。当 agent 判断需要特定技能时,调用 Skill 工具将完整的 SKILL.md 内容加载到上下文中。这保持了初始提示词的精简。一个正在修复 Spring Boot bug 的 agent 不需要加载 Kubernetes 部署技能——那会浪费上下文窗口空间,还可能用无关指令干扰模型。",
"alternatives": "预加载所有技能可以保证模型始终拥有全部知识,但会在无关技能上浪费 token 并可能达到上下文限制。推荐系统(模型建议技能,人类审批)会增加延迟。懒加载让模型在需要时自助获取所需知识。"
},
"ja": {
"title": "起動時ではなくオンデマンドでスキルを読み込み",
"description": "スキルは起動時に読み込まれません。エージェントは最初、スキルの名前と説明(フロントマターから)のみを持ちます。エージェントが特定のスキルが必要だと判断すると、Skill ツールを呼び出して完全な SKILL.md の内容をコンテキストに読み込みます。これにより初期プロンプトを小さく保ちます。Python のバグを修正しているエージェントに Kubernetes デプロイのスキルは不要です――コンテキストウィンドウの無駄遣いであり、無関係な指示でモデルを混乱させかねません。"
"description": "スキルは起動時に読み込まれません。エージェントは最初、スキルの名前と説明(フロントマターから)のみを持ちます。エージェントが特定のスキルが必要だと判断すると、Skill ツールを呼び出して完全な SKILL.md の内容をコンテキストに読み込みます。これにより初期プロンプトを小さく保ちます。Spring Boot のバグを修正しているエージェントに Kubernetes デプロイのスキルは不要です――コンテキストウィンドウの無駄遣いであり、無関係な指示でモデルを混乱させかねません。",
"alternatives": "全スキルの事前読み込みはモデルが常に全知識を利用可能であることを保証しますが、無関係なスキルでトークンを浪費しコンテキスト制限に達する可能性があります。推薦システム(モデルがスキルを提案し人間が承認)は遅延を増加させます。遅延読み込みにより、モデルは必要な時に必要な知識を自ら取得できます。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "A separate metadata file (skill.yaml + skill.md) would work but doubles the number of files. Embedding metadata in the markdown (as headings or comments) requires parsing the full file to extract metadata. Frontmatter is a well-established convention (Jekyll, Hugo, Astro) that keeps metadata and content co-located but separately parseable.",
"zh": {
"title": "SKILL.md 采用 YAML Frontmatter + Markdown 正文",
"description": "每个 SKILL.md 文件有两部分:YAML frontmatter(名称、描述、globs)和 markdown 正文(实际指令)。Frontmatter 作为技能注册表的元数据——当 agent 问'有哪些可用技能'时,展示的就是这些信息。正文是按需加载的有效负载。这种分离意味着可以列出 100 个技能(每个只读几字节的 frontmatter)而不必加载 100 套完整指令集(每套可能数千 token)。"
"description": "每个 SKILL.md 文件有两部分:YAML frontmatter(名称、描述、globs)和 markdown 正文(实际指令)。Frontmatter 作为技能注册表的元数据——当 agent 问'有哪些可用技能'时,展示的就是这些信息。正文是按需加载的有效负载。这种分离意味着可以列出 100 个技能(每个只读几字节的 frontmatter)而不必加载 100 套完整指令集(每套可能数千 token)。",
"alternatives": "使用独立的元数据文件(skill.yaml + skill.md)可行但会使文件数翻倍。将元数据嵌入 markdown(作为标题或注释)需要解析整个文件才能提取元数据。Frontmatter 是一种成熟的约定(Jekyll、Hugo、Astro),使元数据与内容并存但可独立解析。"
},
"ja": {
"title": "SKILL.md で YAML フロントマター + Markdown 本文",
"description": "各 SKILL.md ファイルは2つの部分で構成されます:YAML フロントマター(名前、説明、globs)と Markdown 本文(実際の指示)。フロントマターはスキルレジストリのメタデータとして機能し、エージェントが「どんなスキルが利用可能か」と問い合わせた際に一覧表示されます。本文はオンデマンドで読み込まれるペイロードです。この分離により、100個のスキル一覧表示(各数バイトのフロントマターのみ読み取り)が100個の完全な指示セット(各数千トークン)のロードなしに可能になります。"
"description": "各 SKILL.md ファイルは2つの部分で構成されます:YAML フロントマター(名前、説明、globs)と Markdown 本文(実際の指示)。フロントマターはスキルレジストリのメタデータとして機能し、エージェントが「どんなスキルが利用可能か」と問い合わせた際に一覧表示されます。本文はオンデマンドで読み込まれるペイロードです。この分離により、100個のスキル一覧表示(各数バイトのフロントマターのみ読み取り)が100個の完全な指示セット(各数千トークン)のロードなしに可能になります。",
"alternatives": "メタデータファイルを分離する(skill.yaml + skill.md)方式は機能しますがファイル数が2倍になります。メタデータを Markdown に埋め込む(見出しやコメントとして)場合、メタデータの抽出に全ファイルの解析が必要です。フロントマターは確立された慣例(Jekyll、Hugo、Astro)であり、メタデータとコンテンツを同じ場所に保ちつつ個別に解析可能です。"
}
}
]
+16 -8
View File
@@ -8,11 +8,13 @@
"alternatives": "A single compression strategy (e.g., always summarize at 80% capacity) would be simpler but wasteful -- most of the time, microcompact alone keeps things manageable. A sliding window (drop oldest N messages) is cheap but loses important context. The three-layer approach gives the best token efficiency: cheap cleanup constantly, expensive summarization rarely.",
"zh": {
"title": "三层压缩策略",
"description": "上下文管理使用三个独立的层次,各有不同的成本收益比。(1) 微压缩每轮都运行,几乎零成本:它截断旧消息中的 tool_result 块,去除不再需要的冗长命令输出。(2) 自动压缩在 token 数超过阈值时触发:调用 LLM 生成对话摘要,代价高但能大幅缩减上下文。(3) 手动压缩由用户触发,用于明确的'重新开始'场景。分层意味着低成本操作持续运行(保持上下文整洁),而高成本操作很少触发(仅在真正需要时)。"
"description": "上下文管理使用三个独立的层次,各有不同的成本收益比。(1) 微压缩每轮都运行,几乎零成本:它截断旧消息中的 tool_result 块,去除不再需要的冗长命令输出。(2) 自动压缩在 token 数超过阈值时触发:调用 LLM 生成对话摘要,代价高但能大幅缩减上下文。(3) 手动压缩由用户触发,用于明确的'重新开始'场景。分层意味着低成本操作持续运行(保持上下文整洁),而高成本操作很少触发(仅在真正需要时)。",
"alternatives": "单一压缩策略(如始终在 80% 容量时摘要)更简单但浪费——大多数时候仅微压缩就能维持可控。滑动窗口(丢弃最早的 N 条消息)成本低但会丢失重要上下文。三层方式提供最佳 token 效率:低成本清理持续进行,高成本摘要很少触发。"
},
"ja": {
"title": "3層圧縮戦略",
"description": "コンテキスト管理は、異なるコスト・効果プロファイルを持つ3つの層を使用します。(1) マイクロコンパクトは毎ターン実行されほぼ無コスト:古いメッセージの tool_result ブロックを切り詰め、不要な冗長出力を除去します。(2) 自動コンパクトはトークン数が閾値を超えると発動:LLM を呼び出して会話の要約を生成し、コストは高いがコンテキストサイズを劇的に削減します。(3) 手動コンパクトはユーザーが明示的に「最初からやり直し」する時に使用します。この階層化により、安価な操作が常に実行され(コンテキストを整頓)、高価な操作はめったに実行されません(本当に必要な時のみ)。"
"description": "コンテキスト管理は、異なるコスト・効果プロファイルを持つ3つの層を使用します。(1) マイクロコンパクトは毎ターン実行されほぼ無コスト:古いメッセージの tool_result ブロックを切り詰め、不要な冗長出力を除去します。(2) 自動コンパクトはトークン数が閾値を超えると発動:LLM を呼び出して会話の要約を生成し、コストは高いがコンテキストサイズを劇的に削減します。(3) 手動コンパクトはユーザーが明示的に「最初からやり直し」する時に使用します。この階層化により、安価な操作が常に実行され(コンテキストを整頓)、高価な操作はめったに実行されません(本当に必要な時のみ)。",
"alternatives": "単一の圧縮戦略(例:常に80%の容量で要約)はシンプルですが無駄です――ほとんどの場合、マイクロコンパクトだけで管理可能です。スライディングウィンドウ(最も古い N 件を破棄)は安価ですが重要なコンテキストを失います。3層アプローチは最良のトークン効率を提供します:安価なクリーンアップを常時、高価な要約をまれに実行します。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "A percentage-based threshold (compress when context is 80% full) adapts to different context window sizes but doesn't account for the fixed cost of generating a summary. A fixed threshold of 10K would compress more aggressively but often isn't worth it. The 20K value was chosen empirically: it's the point where compression savings consistently outweigh the quality loss from summarization.",
"zh": {
"title": "最小节省量 = 20,000 Token 才触发压缩",
"description": "自动压缩仅在估算节省量(当前 token 数减去预估摘要大小)超过 20,000 token 时才触发。压缩不是免费的:摘要本身会消耗 token,还有生成摘要的 API 调用成本。如果对话只有 25,000 token,压缩可能节省 5,000 token,但需要一次 API 调用,且产出的摘要可能不如原文连贯。20K 的阈值确保只在节省量明显超过开销时才进行压缩。"
"description": "自动压缩仅在估算节省量(当前 token 数减去预估摘要大小)超过 20,000 token 时才触发。压缩不是免费的:摘要本身会消耗 token,还有生成摘要的 API 调用成本。如果对话只有 25,000 token,压缩可能节省 5,000 token,但需要一次 API 调用,且产出的摘要可能不如原文连贯。20K 的阈值确保只在节省量明显超过开销时才进行压缩。",
"alternatives": "基于百分比的阈值(上下文 80% 满时压缩)能适应不同大小的上下文窗口,但未考虑生成摘要的固定成本。10K 的固定阈值会更积极地压缩,但往往不值得。20K 值是经验选择:在这个点上,压缩节省量稳定地超过摘要带来的质量损失。"
},
"ja": {
"title": "圧縮前に MIN_SAVINGS = 20,000 トークンが必要",
"description": "自動コンパクトは推定節約量(現在のトークン数マイナス推定要約サイズ)が20,000トークンを超えた場合にのみ発動します。圧縮は無料ではありません:要約自体がトークンを消費し、さらに生成のための API コール費用がかかります。会話が25,000トークンしかない場合、圧縮で5,000トークン節約できても、API コールが必要で元の会話より一貫性の低い要約になる可能性があります。20K の閾値は、節約量がオーバーヘッドを確実に上回る場合にのみ圧縮を実行することを保証します。"
"description": "自動コンパクトは推定節約量(現在のトークン数マイナス推定要約サイズ)が20,000トークンを超えた場合にのみ発動します。圧縮は無料ではありません:要約自体がトークンを消費し、さらに生成のための API コール費用がかかります。会話が25,000トークンしかない場合、圧縮で5,000トークン節約できても、API コールが必要で元の会話より一貫性の低い要約になる可能性があります。20K の閾値は、節約量がオーバーヘッドを確実に上回る場合にのみ圧縮を実行することを保証します。",
"alternatives": "パーセンテージベースの閾値(コンテキストが80%に達したら圧縮)は異なるコンテキストウィンドウサイズに適応しますが、要約生成の固定コストを考慮していません。10K の固定閾値はより積極的に圧縮しますが、多くの場合割に合いません。20K の値は経験的に選ばれました:圧縮の節約が要約による品質低下を一貫して上回るポイントです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Keeping the last 5-10 messages alongside the summary preserves recent detail and gives the model more to work with. But it creates the overlap problem described above, and makes the total context size less predictable. Some systems use a 'sliding window + summary' approach which works but requires careful tuning of the overlap region.",
"zh": {
"title": "摘要替换全部消息,而非保留部分历史",
"description": "自动压缩触发时,生成摘要并替换全部消息历史,不会在摘要旁保留最近的 N 条消息。这避免了一个微妙的连贯性问题:如果同时保留近期消息和旧消息的摘要,模型会看到重叠内容的两种表示。摘要可能说'我们决定使用方案 X',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。"
"description": "自动压缩触发时,生成摘要并替换全部消息历史,不会在摘要旁保留最近的 N 条消息。这避免了一个微妙的连贯性问题:如果同时保留近期消息和旧消息的摘要,模型会看到重叠内容的两种表示。摘要可能说'我们决定使用方案 X',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。",
"alternatives": "在摘要旁保留最近的 5-10 条消息可以保留近期细节,给模型更多信息。但这会产生上述重叠问题,且使总上下文大小难以预测。有些系统使用「滑动窗口 + 摘要」方式,可行但需要仔细调整重叠区域。"
},
"ja": {
"title": "要約が部分的な履歴ではなく全メッセージを置換",
"description": "自動コンパクトが発動すると、要約を生成してメッセージ履歴の全体をその要約で置換します。要約と並べて直近 N 件のメッセージを保持することはしません。これにより微妙な一貫性の問題を回避します:直近のメッセージと古いメッセージの要約を併存させると、モデルは重複するコンテンツの2つの表現を見ることになります。要約が「アプローチ X を使うことに決めた」と言う一方で、直近のメッセージにはまだ検討過程が表示されているかもしれず、矛盾するシグナルを生じます。クリーンな要約は単一の一貫した物語です。"
"description": "自動コンパクトが発動すると、要約を生成してメッセージ履歴の全体をその要約で置換します。要約と並べて直近 N 件のメッセージを保持することはしません。これにより微妙な一貫性の問題を回避します:直近のメッセージと古いメッセージの要約を併存させると、モデルは重複するコンテンツの2つの表現を見ることになります。要約が「アプローチ X を使うことに決めた」と言う一方で、直近のメッセージにはまだ検討過程が表示されているかもしれず、矛盾するシグナルを生じます。クリーンな要約は単一の一貫した物語です。",
"alternatives": "要約と並べて直近 5〜10 件のメッセージを保持すれば、最近の詳細が保たれモデルに多くの情報を与えられます。しかし上述の重複問題が生じ、合計コンテキストサイズの予測が困難になります。一部のシステムは「スライディングウィンドウ+要約」方式を使いますが、重複領域の慎重な調整が必要です。"
}
},
{
@@ -50,11 +56,13 @@
"alternatives": "Not archiving saves disk space but makes debugging hard -- when the agent makes a mistake, you can't see what it was 'thinking' 200 messages ago because that context was compressed away. Database storage (SQLite) would provide queryability but adds a dependency. JSONL is the simplest format that supports append-only writes and line-by-line processing.",
"zh": {
"title": "完整对话以 JSONL 格式归档到磁盘",
"description": "尽管上下文在内存中被压缩,完整的未压缩对话仍会追加到磁盘上的 JSONL 文件中。每条消息、每次工具调用、每个结果都不会丢失。压缩对内存上下文是有损操作,但对永久记录是无损的。事后分析(调试 agent 行为、计算 token 用量、提取训练数据)始终可以基于完整记录进行。JSONL 格式仅追加写入,对并发写入安全,易于流式处理。"
"description": "尽管上下文在内存中被压缩,完整的未压缩对话仍会追加到磁盘上的 JSONL 文件中。每条消息、每次工具调用、每个结果都不会丢失。压缩对内存上下文是有损操作,但对永久记录是无损的。事后分析(调试 agent 行为、计算 token 用量、提取训练数据)始终可以基于完整记录进行。JSONL 格式仅追加写入,对并发写入安全,易于流式处理。",
"alternatives": "不归档可节省磁盘空间,但调试变得困难——当 agent 犯错时,你无法看到 200 条消息之前它在'想'什么,因为那段上下文已被压缩掉了。数据库存储(SQLite)可提供查询能力但增加了依赖。JSONL 是支持仅追加写入和逐行处理的最简格式。"
},
"ja": {
"title": "完全な会話を JSONL としてディスクに保存",
"description": "メモリ上でコンテキストが圧縮されても、完全な非圧縮会話はディスク上の JSONL ファイルに追記されます。全てのメッセージ、全てのツール呼び出し、全ての結果――何も失われません。圧縮はインメモリコンテキストに対しては不可逆ですが、永続記録に対しては可逆です。事後分析(エージェントの挙動デバッグ、トークン使用量の計算、学習データの抽出)は常に完全な記録から行えます。JSONL フォーマットは追記専用で、並行書き込みに安全であり行単位の処理が容易です。"
"description": "メモリ上でコンテキストが圧縮されても、完全な非圧縮会話はディスク上の JSONL ファイルに追記されます。全てのメッセージ、全てのツール呼び出し、全ての結果――何も失われません。圧縮はインメモリコンテキストに対しては不可逆ですが、永続記録に対しては可逆です。事後分析(エージェントの挙動デバッグ、トークン使用量の計算、学習データの抽出)は常に完全な記録から行えます。JSONL フォーマットは追記専用で、並行書き込みに安全であり行単位の処理が容易です。",
"alternatives": "アーカイブしなければディスク容量は節約できますがデバッグが困難になります――エージェントがミスした時、200メッセージ前に何を「考えていた」か確認できません。そのコンテキストは圧縮で失われているからです。データベースストレージ(SQLite)はクエリ機能を提供しますが依存関係が増えます。JSONL は追記専用書き込みと行単位処理をサポートする最もシンプルなフォーマットです。"
}
}
]
+16 -8
View File
@@ -8,11 +8,13 @@
"alternatives": "In-memory storage (like v2's TodoWrite) is simpler and faster but loses state on crash and doesn't work across multiple agent processes. A proper database (SQLite, Redis) would provide ACID guarantees and better concurrency, but adds a dependency and operational complexity. Files are the zero-dependency persistence layer that works everywhere.",
"zh": {
"title": "任务存储为 JSON 文件,而非内存",
"description": "任务以 JSON 文件形式持久化在 .tasks/ 目录中,而非保存在内存里。这有三个关键好处:(1) 任务在进程崩溃后仍然存在——如果 agent 在任务中途崩溃,重启后任务板仍在磁盘上;(2) 多个 agent 可以读写同一任务目录,无需共享内存即可实现多代理协调;(3) 人类可以查看和手动编辑任务文件来调试。文件系统就是共享数据库。"
"description": "任务以 JSON 文件形式持久化在 .tasks/ 目录中,而非保存在内存里。这有三个关键好处:(1) 任务在进程崩溃后仍然存在——如果 agent 在任务中途崩溃,重启后任务板仍在磁盘上;(2) 多个 agent 可以读写同一任务目录,无需共享内存即可实现多代理协调;(3) 人类可以查看和手动编辑任务文件来调试。文件系统就是共享数据库。",
"alternatives": "内存存储(如 v2 的 TodoWrite)更简单快速,但崩溃时丢失状态,且无法跨多个 agent 进程工作。正式数据库(SQLite、Redis)能提供 ACID 保证和更好的并发性,但增加了依赖和运维复杂度。文件是零依赖的持久化层,在任何环境都能工作。"
},
"ja": {
"title": "タスクをメモリではなく JSON ファイルとして保存",
"description": "タスクはメモリ内ではなく .tasks/ ディレクトリに JSON ファイルとして永続化されます。3つの重要な利点があります:(1) プロセスのクラッシュ後もタスクが存続する――エージェントがタスク途中でクラッシュしても、再起動時にタスクボードはディスク上に残っています。(2) 複数のエージェントが同じタスクディレクトリを読み書きでき、共有メモリなしにマルチエージェント連携が可能になります。(3) 人間がデバッグのためにタスクファイルを検査・手動編集できます。ファイルシステムが共有データベースになります。"
"description": "タスクはメモリ内ではなく .tasks/ ディレクトリに JSON ファイルとして永続化されます。3つの重要な利点があります:(1) プロセスのクラッシュ後もタスクが存続する――エージェントがタスク途中でクラッシュしても、再起動時にタスクボードはディスク上に残っています。(2) 複数のエージェントが同じタスクディレクトリを読み書きでき、共有メモリなしにマルチエージェント連携が可能になります。(3) 人間がデバッグのためにタスクファイルを検査・手動編集できます。ファイルシステムが共有データベースになります。",
"alternatives": "インメモリストレージ(v2 の TodoWrite など)はシンプルで高速ですが、クラッシュ時に状態を失い、複数のエージェントプロセス間で動作しません。適切なデータベース(SQLite、Redis)は ACID 保証とより良い並行性を提供しますが、依存関係と運用の複雑さが増します。ファイルはどこでも動作するゼロ依存の永続化レイヤーです。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Simple priority ordering (high/medium/low) doesn't capture 'task B literally cannot start until task A finishes.' A centralized coordinator that assigns tasks in order would work but creates a single point of failure and bottleneck. Declarative dependencies let each agent independently determine what it can work on by reading the task files.",
"zh": {
"title": "任务具有 blocks/blockedBy 依赖字段",
"description": "每个任务可以声明它阻塞哪些任务(下游依赖)以及它被哪些任务阻塞(上游依赖)。Agent 不会开始有未解决 blockedBy 依赖的任务。这对多代理协调至关重要:当 Agent A 在编写数据库 schema、Agent B 需要写查询时,Agent B 的任务被 Agent A 的任务阻塞。没有依赖关系,两个 agent 可能同时开始,而 Agent B 会针对一个尚不存在的 schema 工作。"
"description": "每个任务可以声明它阻塞哪些任务(下游依赖)以及它被哪些任务阻塞(上游依赖)。Agent 不会开始有未解决 blockedBy 依赖的任务。这对多代理协调至关重要:当 Agent A 在编写数据库 schema、Agent B 需要写查询时,Agent B 的任务被 Agent A 的任务阻塞。没有依赖关系,两个 agent 可能同时开始,而 Agent B 会针对一个尚不存在的 schema 工作。",
"alternatives": "简单的优先级排序(高/中/低)无法表达'任务 B 必须等任务 A 完成才能开始'。中央协调器按顺序分配任务可行,但会形成单点故障和瓶颈。声明式依赖让每个 agent 通过读取任务文件独立判断可以处理什么。"
},
"ja": {
"title": "タスクに blocks/blockedBy 依存関係フィールド",
"description": "各タスクは、自分がブロックするタスク(下流の依存先)と、自分をブロックするタスク(上流の依存元)を宣言できます。エージェントは未解決の blockedBy 依存がある タスクを開始しません。これはマルチエージェント連携に不可欠です:エージェント A がデータベーススキーマを書いていてエージェント B がそれに対するクエリを書く必要がある場合、B のタスクは A のタスクにブロックされます。依存関係がなければ両エージェントが同時に開始し、B はまだ存在しないスキーマに対して作業することになります。"
"description": "各タスクは、自分がブロックするタスク(下流の依存先)と、自分をブロックするタスク(上流の依存元)を宣言できます。エージェントは未解決の blockedBy 依存がある タスクを開始しません。これはマルチエージェント連携に不可欠です:エージェント A がデータベーススキーマを書いていてエージェント B がそれに対するクエリを書く必要がある場合、B のタスクは A のタスクにブロックされます。依存関係がなければ両エージェントが同時に開始し、B はまだ存在しないスキーマに対して作業することになります。",
"alternatives": "単純な優先度順序(高/中/低)では「タスク B はタスク A が完了するまで文字通り開始できない」を表現できません。タスクを順番に割り当てる中央コーディネーターは機能しますが、単一障害点とボトルネックを生みます。宣言的な依存関係により、各エージェントがタスクファイルを読んで独立に作業可能なものを判断できます。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Using only Todo keeps the model minimal but weak for long-running or collaborative work. Using only Task everywhere maximizes consistency but can feel heavy for tiny one-off tasks.",
"zh": {
"title": "Task 为课程主线,Todo 仍有适用场景",
"description": "TaskManager 延续了 Todo 的心智模型,并在本课程 s07 之后成为默认主线。两者都管理带状态的任务项,但 TaskManager 增加了文件持久化(崩溃后可恢复)、依赖追踪(blocks/blockedBy)、owner 字段与多进程协作能力。Todo 仍适合短、线性、一次性的轻量跟踪。"
"description": "TaskManager 延续了 Todo 的心智模型,并在本课程 s07 之后成为默认主线。两者都管理带状态的任务项,但 TaskManager 增加了文件持久化(崩溃后可恢复)、依赖追踪(blocks/blockedBy)、owner 字段与多进程协作能力。Todo 仍适合短、线性、一次性的轻量跟踪。",
"alternatives": "仅使用 Todo 保持模型最小化,但对长期或协作任务能力不足。全部使用 Task 最大化一致性,但对微小的一次性任务显得过重。"
},
"ja": {
"title": "Task を主線にしつつ Todo も併存",
"description": "TaskManager は Todo のメンタルモデルを拡張し、本コースでは s07 以降のデフォルトになる。どちらもステータス付き作業項目を扱うが、TaskManager にはファイル永続化(クラッシュ耐性)、依存関係追跡(blocks/blockedBy)、owner、マルチプロセス協調がある。Todo は短く直線的な単発作業では引き続き有効。"
"description": "TaskManager は Todo のメンタルモデルを拡張し、本コースでは s07 以降のデフォルトになる。どちらもステータス付き作業項目を扱うが、TaskManager にはファイル永続化(クラッシュ耐性)、依存関係追跡(blocks/blockedBy)、owner、マルチプロセス協調がある。Todo は短く直線的な単発作業では引き続き有効。",
"alternatives": "Todo のみ使用するとモデルは最小限に保てますが、長期的または協調的な作業には弱くなります。Task のみをどこでも使用すると一貫性は最大化されますが、小さな単発タスクには重く感じることがあります。"
}
},
{
@@ -50,11 +56,13 @@
"alternatives": "Blind overwrite writes are simpler but can corrupt coordination state under parallel execution. A database with optimistic locking would enforce stronger safety, but the course keeps file-based state for zero-dependency teaching.",
"zh": {
"title": "持久化仍需要写入纪律",
"description": "文件持久化能降低上下文丢失,但不会自动消除并发写入风险。写任务状态前应先重读 JSON、校验 `status/blockedBy` 是否符合预期,再原子写回,避免不同 agent 悄悄覆盖彼此状态。"
"description": "文件持久化能降低上下文丢失,但不会自动消除并发写入风险。写任务状态前应先重读 JSON、校验 `status/blockedBy` 是否符合预期,再原子写回,避免不同 agent 悄悄覆盖彼此状态。",
"alternatives": "直接覆盖写入更简单,但在并行执行下可能破坏协调状态。使用乐观锁的数据库可以提供更强的安全保障,但本课程为了零依赖教学而保持基于文件的状态。"
},
"ja": {
"title": "耐久性には書き込み規律が必要",
"description": "ファイル永続化だけでは並行書き込み競合は防げない。更新前に JSON を再読込し、`status/blockedBy` を検証して原子的に保存することで、他エージェントの遷移上書きを防ぐ。"
"description": "ファイル永続化だけでは並行書き込み競合は防げない。更新前に JSON を再読込し、`status/blockedBy` を検証して原子的に保存することで、他エージェントの遷移上書きを防ぐ。",
"alternatives": "盲目な上書きはシンプルですが、並列実行時に協調状態を破損させる可能性があります。楽観的ロック付きデータベースならより強い安全性を確保できますが、本コースではゼロ依存の教育のためにファイルベースの状態を維持しています。"
}
}
]
+23 -17
View File
@@ -3,44 +3,50 @@
"decisions": [
{
"id": "notification-bus",
"title": "threading.Queue as the Notification Bus",
"description": "Background task results are delivered via a threading.Queue instead of direct callbacks. The background thread puts a notification on the queue when its work completes. The main agent loop polls the queue before each LLM call. This decoupling is important: the background thread doesn't need to know anything about the main loop's state or timing. It just drops a message on the queue and moves on. The main loop picks it up at its own pace -- never mid-API-call, never mid-tool-execution. No race conditions, no callback hell.",
"alternatives": "Direct callbacks (background thread calls a function in the main thread) would deliver results faster but create thread-safety issues -- the callback might fire while the main thread is in the middle of building a request. Event-driven systems (asyncio, event emitters) work but add complexity. A queue is the simplest thread-safe communication primitive.",
"title": "ConcurrentLinkedQueue as the Notification Bus",
"description": "Background task results are delivered via a ConcurrentLinkedQueue instead of direct callbacks. The background thread puts a notification on the queue when its work completes. The main agent loop polls the queue before each LLM call. This decoupling is important: the background thread doesn't need to know anything about the main loop's state or timing. It just drops a message on the queue and moves on. The main loop picks it up at its own pace -- never mid-API-call, never mid-tool-execution. No race conditions, no callback hell.",
"alternatives": "Direct callbacks (background thread calls a function in the main thread) would deliver results faster but create thread-safety issues -- the callback might fire while the main thread is in the middle of building a request. Event-driven systems (CompletableFuture, event emitters) work but add complexity. A queue is the simplest thread-safe communication primitive.",
"zh": {
"title": "用 threading.Queue 作为通知总线",
"description": "后台任务结果通过 threading.Queue 传递,而非直接回调。后台线程在工作完成时向队列放入通知,主 agent 循环在每次 LLM 调用前轮询队列。这种解耦很重要:后台线程无需了解主循环的状态或时序,只需往队列放入消息然后继续。主循环按自己的节奏取出消息——永远不会在 API 调用中途或工具执行中途。没有竞争条件,没有回调地狱。"
"title": "用 ConcurrentLinkedQueue 作为通知总线",
"description": "后台任务结果通过 ConcurrentLinkedQueue 传递,而非直接回调。后台线程在工作完成时向队列放入通知,主 agent 循环在每次 LLM 调用前轮询队列。这种解耦很重要:后台线程无需了解主循环的状态或时序,只需往队列放入消息然后继续。主循环按自己的节奏取出消息——永远不会在 API 调用中途或工具执行中途。没有竞争条件,没有回调地狱。",
"alternatives": "直接回调(后台线程调用主线程中的函数)能更快传递结果,但会产生线程安全问题——回调可能在主线程正在构建请求时触发。事件驱动系统(CompletableFuture、事件发射器)可行但增加复杂性。队列是最简单的线程安全通信原语。"
},
"ja": {
"title": "threading.Queue を通知バスとして使用",
"description": "バックグラウンドタスクの結果は直接コールバックではなく threading.Queue を通じて配信されます。バックグラウンドスレッドは作業完了時にキューに通知を投入します。メインのエージェントループは各 LLM 呼び出しの前にキューをポーリングします。この疎結合が重要です:バックグラウンドスレッドはメインループの状態やタイミングを一切知る必要がありません。キューにメッセージを入れて先に進むだけです。メインループは自分のペースで取り出します――API 呼び出しの途中でもツール実行の途中でもありません。レースコンディションもコールバック地獄もありません。"
"title": "ConcurrentLinkedQueue を通知バスとして使用",
"description": "バックグラウンドタスクの結果は直接コールバックではなく ConcurrentLinkedQueue を通じて配信されます。バックグラウンドスレッドは作業完了時にキューに通知を投入します。メインのエージェントループは各 LLM 呼び出しの前にキューをポーリングします。この疎結合が重要です:バックグラウンドスレッドはメインループの状態やタイミングを一切知る必要がありません。キューにメッセージを入れて先に進むだけです。メインループは自分のペースで取り出します――API 呼び出しの途中でもツール実行の途中でもありません。レースコンディションもコールバック地獄もありません。",
"alternatives": "直接コールバック(バックグラウンドスレッドがメインスレッドの関数を呼び出す)は結果をより速く届けますが、スレッドセーフティの問題が生じます――メインスレッドがリクエストを構築中にコールバックが発火する可能性があります。イベント駆動システム(CompletableFuture、イベントエミッター)は機能しますが複雑さが増します。キューは最もシンプルなスレッドセーフな通信プリミティブです。"
}
},
{
"id": "daemon-threads",
"title": "Background Tasks Run as Daemon Threads",
"description": "Background task threads are created with daemon=True. In Python, daemon threads are killed automatically when the main thread exits. This prevents a common problem: if the main agent completes its work and exits, but a background thread is still running (waiting on a long API call, stuck in a loop), the process would hang indefinitely. With daemon threads, exit is clean -- the main thread finishes, all daemon threads die, process exits. No zombie processes, no cleanup code needed.",
"alternatives": "Non-daemon threads with explicit cleanup (join with timeout, then terminate) give more control over shutdown but require careful lifecycle management. Process-based parallelism (multiprocessing) provides stronger isolation but higher overhead. Daemon threads are the pragmatic choice: minimal code, correct behavior in the common case.",
"title": "Background Tasks Run as Virtual Threads",
"description": "Background tasks are launched as virtual threads via Thread.startVirtualThread(). In Java 21+, virtual threads are lightweight, managed by the JVM, and do not prevent the main thread from exiting. This prevents a common problem: if the main agent completes its work and exits, but a background thread is still running (waiting on a long API call, stuck in a loop), the process would hang indefinitely. With virtual threads (which are daemon by default), exit is clean -- the main thread finishes, all virtual threads are terminated, process exits. No zombie processes, no cleanup code needed.",
"alternatives": "Platform threads with explicit cleanup (join with timeout, then interrupt) give more control over shutdown but require careful lifecycle management. Process-based parallelism (ProcessBuilder) provides stronger isolation but higher overhead. Virtual threads are the pragmatic choice: minimal code, correct behavior in the common case.",
"zh": {
"title": "后台任务以守护线程运行",
"description": "后台任务线程以 daemon=True 创建。在 Python 中,守护线程在主线程退出时自动被终止。这防止了一个常见问题:如果主 agent 完成工作并退出,但后台线程仍在运行(等待一个长时间 API 调用或陷入循环),进程会无限挂起。使用守护线程,退出是干净的——主线程结束,所有守护线程自动终止,进程退出。没有僵尸进程,不需要清理代码。"
"title": "后台任务以虚拟线程运行",
"description": "后台任务通过 Thread.startVirtualThread() 以虚拟线程启动。在 Java 21+ 中,虚拟线程是轻量级的,由 JVM 管理,不会阻止主线程退出。这防止了一个常见问题:如果主 agent 完成工作并退出,但后台线程仍在运行(等待一个长时间 API 调用或陷入循环),进程会无限挂起。虚拟线程默认是守护线程,退出是干净的——主线程结束,所有虚拟线程自动终止,进程退出。没有僵尸进程,不需要清理代码。",
"alternatives": "使用平台线程加显式清理(带超时的 join 后 interrupt)可以获得更多关闭控制,但需要谨慎的生命周期管理。基于进程的并行(ProcessBuilder)提供更强的隔离但开销更高。虚拟线程是务实的选择:代码最少,常见场景下行为正确。"
},
"ja": {
"title": "バックグラウンドタスクはデーモンスレッドとして実行",
"description": "バックグラウンドタスクのスレッドは daemon=True で作成されます。Python ではデーモンスレッドはメインスレッドの終了時に自動的に終了されます。これにより一般的な問題を防ぎます:メインエージェントが作業を完了して終了しても、バックグラウンドスレッドがまだ実行中(長い API 呼び出しを待機、ループに陥っている)だとプロセスが無限にハングします。デーモンスレッドならクリーンに終了できます――メインスレッドが終了すると全デーモンスレッドが自動終了し、プロセスが終了します。ゾンビプロセスもクリーンアップコードも不要です。"
"title": "バックグラウンドタスクは仮想スレッドとして実行",
"description": "バックグラウンドタスクは Thread.startVirtualThread() により仮想スレッドとして起動されます。Java 21+ では仮想スレッドは軽量で JVM が管理し、メインスレッドの終了を妨げません。これにより一般的な問題を防ぎます:メインエージェントが作業を完了して終了しても、バックグラウンドスレッドがまだ実行中(長い API 呼び出しを待機、ループに陥っている)だとプロセスが無限にハングします。仮想スレッドはデフォルトでデーモンスレッドなのでクリーンに終了できます――メインスレッドが終了すると全仮想スレッドが自動終了し、プロセスが終了します。ゾンビプロセスもクリーンアップコードも不要です。",
"alternatives": "プラットフォームスレッドと明示的なクリーンアップ(タイムアウト付き join 後に interrupt)はシャットダウンをより細かく制御できますが、慎重なライフサイクル管理が必要です。プロセスベースの並列処理(ProcessBuilder)はより強い分離を提供しますがオーバーヘッドが大きくなります。仮想スレッドは実用的な選択です:最小限のコードで一般的なケースでは正しく動作します。"
}
},
{
"id": "attachment-format",
"title": "Structured Notification Format with Type Tags",
"description": "Notifications from background tasks use a structured format: {\"type\": \"attachment\", \"attachment\": {status, result, ...}} instead of plain text strings. The type tag lets the main loop handle different notification types differently: an 'attachment' might be injected into the conversation as a tool_result, while a 'status_update' might just update a progress indicator. Machine-readable notifications also enable programmatic filtering (show only errors, suppress progress updates) and UI rendering (display status as a progress bar, not raw text).",
"alternatives": "Plain text notifications are simpler but lose structure. The main loop would have to parse free-form text to determine what happened, which is fragile. A class hierarchy (StatusNotification, ResultNotification, ErrorNotification) is more Pythonic but less portable -- JSON structures work the same way regardless of language or serialization format.",
"alternatives": "Plain text notifications are simpler but lose structure. The main loop would have to parse free-form text to determine what happened, which is fragile. A class hierarchy (StatusNotification, ResultNotification, ErrorNotification) is more idiomatic in Java but less portable -- JSON structures work the same way regardless of language or serialization format.",
"zh": {
"title": "带类型标签的结构化通知格式",
"description": "后台任务的通知使用结构化格式:{\"type\": \"attachment\", \"attachment\": {status, result, ...}},而非纯文本字符串。类型标签让主循环可以区别处理不同通知类型:attachment 可能作为 tool_result 注入对话,而 status_update 可能只更新进度指示器。机器可读的通知还支持程序化过滤(只显示错误、抑制进度更新)和 UI 渲染(将状态显示为进度条而非原始文本)。"
"description": "后台任务的通知使用结构化格式:{\"type\": \"attachment\", \"attachment\": {status, result, ...}},而非纯文本字符串。类型标签让主循环可以区别处理不同通知类型:attachment 可能作为 tool_result 注入对话,而 status_update 可能只更新进度指示器。机器可读的通知还支持程序化过滤(只显示错误、抑制进度更新)和 UI 渲染(将状态显示为进度条而非原始文本)。",
"alternatives": "纯文本通知更简单但丢失了结构。主循环必须解析自由格式文本来判断发生了什么,这很脆弱。类层级结构(StatusNotification、ResultNotification、ErrorNotification)在 Java 中更惯用但移植性较差——JSON 结构无论何种语言或序列化格式都以相同方式工作。"
},
"ja": {
"title": "型タグ付き構造化通知フォーマット",
"description": "バックグラウンドタスクからの通知は構造化フォーマットを使用します:プレーンテキストではなく {\"type\": \"attachment\", \"attachment\": {status, result, ...}} です。型タグによりメインループは異なる通知タイプを異なる方法で処理できます:attachment は会話に tool_result として注入され、status_update は進捗インジケーターの更新のみを行うかもしれません。機械可読な通知はプログラム的なフィルタリング(エラーのみ表示、進捗更新の抑制)や UI レンダリング(ステータスを生テキストではなくプログレスバーとして表示)も可能にします。"
"description": "バックグラウンドタスクからの通知は構造化フォーマットを使用します:プレーンテキストではなく {\"type\": \"attachment\", \"attachment\": {status, result, ...}} です。型タグによりメインループは異なる通知タイプを異なる方法で処理できます:attachment は会話に tool_result として注入され、status_update は進捗インジケーターの更新のみを行うかもしれません。機械可読な通知はプログラム的なフィルタリング(エラーのみ表示、進捗更新の抑制)や UI レンダリング(ステータスを生テキストではなくプログレスバーとして表示)も可能にします。",
"alternatives": "プレーンテキスト通知はシンプルですが構造が失われます。メインループは何が起きたかを判断するために自由形式のテキストを解析する必要があり、これは脆弱です。クラス階層(StatusNotification、ResultNotification、ErrorNotification)は Java ではより慣用的ですが移植性が低くなります――JSON 構造は言語やシリアライゼーション形式に関係なく同じように機能します。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "One-shot subagents (s04 style) are simpler and provide perfect context isolation -- no risk of one task's context polluting another. But the re-learning cost is high: every new task starts from zero. A middle ground (subagents with shared memory/knowledge base) was considered but adds complexity without the full benefit of persistent identity and state.",
"zh": {
"title": "持久化队友 vs 一次性子智能体",
"description": "在 s04 中,子智能体是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 s09 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。"
"description": "在 s04 中,子智能体是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 s09 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。",
"alternatives": "一次性子智能体(s04 风格)更简单,提供完美的上下文隔离——不会有一个任务的上下文污染另一个任务的风险。但重新学习的成本很高:每个新任务都从零开始。曾考虑过折中方案(带共享内存/知识库的子智能体),但增加了复杂度,却无法获得持久化身份和状态的全部收益。"
},
"ja": {
"title": "永続的なチームメイト vs 使い捨てサブエージェント",
"description": "s04 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。s09 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。"
"description": "s04 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。s09 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。",
"alternatives": "使い捨てサブエージェント(s04 スタイル)はシンプルで完全なコンテキスト分離を提供します——あるタスクのコンテキストが別のタスクを汚染するリスクがありません。しかし再学習コストが高く、新しいタスクはすべてゼロから始まります。中間案(共有メモリ/ナレッジベース付きサブエージェント)も検討されましたが、永続的なアイデンティティと状態の完全な恩恵なしに複雑さだけが増します。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "In-memory team registries are faster but don't survive process restarts and require a central process to maintain. Service discovery (like DNS or a discovery server) is more robust at scale but overkill for a local multi-agent system. File-based config is the simplest approach that works across independent processes.",
"zh": {
"title": "团队配置持久化到 .teams/{name}/config.json",
"description": "团队结构(成员名称、角色、agent ID)存储在 JSON 配置文件中,而非任何 agent 的内存中。任何 agent 都可以通过读取配置文件发现队友——无需发现服务或共享内存。如果 agent 崩溃并重启,它读取配置即可知道团队中还有谁。这与 s07 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。"
"description": "团队结构(成员名称、角色、agent ID)存储在 JSON 配置文件中,而非任何 agent 的内存中。任何 agent 都可以通过读取配置文件发现队友——无需发现服务或共享内存。如果 agent 崩溃并重启,它读取配置即可知道团队中还有谁。这与 s07 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。",
"alternatives": "内存中的团队注册表更快,但无法在进程重启后保留,且需要中心进程来维护。服务发现(如 DNS 或发现服务器)在大规模场景下更健壮,但对本地多智能体系统来说过于复杂。基于文件的配置是跨独立进程最简单的可行方案。"
},
"ja": {
"title": "チーム設定を .teams/{name}/config.json に永続化",
"description": "チーム構成(メンバー名、役割、エージェント ID)はエージェントのメモリではなく JSON 設定ファイルに保存されます。どのエージェントも設定ファイルを読むことでチームメイトを発見できます――ディスカバリーサービスや共有メモリは不要です。エージェントがクラッシュして再起動した場合、設定を読んで他のチームメンバーを把握します。これは s07 の思想と一貫しています:ファイルシステムが連携レイヤーです。"
"description": "チーム構成(メンバー名、役割、エージェント ID)はエージェントのメモリではなく JSON 設定ファイルに保存されます。どのエージェントも設定ファイルを読むことでチームメイトを発見できます――ディスカバリーサービスや共有メモリは不要です。エージェントがクラッシュして再起動した場合、設定を読んで他のチームメンバーを把握します。これは s07 の思想と一貫しています:ファイルシステムが連携レイヤーです。",
"alternatives": "インメモリのチームレジストリはより高速ですが、プロセス再起動後に生存せず、維持するための中央プロセスが必要です。サービスディスカバリー(DNS やディスカバリーサーバーなど)は大規模では堅牢ですが、ローカルマルチエージェントシステムには過剰です。ファイルベースの設定は独立プロセス間で機能する最もシンプルなアプローチです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Giving all agents identical tools is simpler and more egalitarian, but in practice leads to coordination chaos -- multiple agents trying to manage each other, creating conflicting task assignments. Static role-based filtering is predictable and easy to reason about.",
"zh": {
"title": "队友获得工具子集,组长获得全部工具",
"description": "团队组长获得 ALL_TOOLS(包括 spawn、send、read_inbox 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。"
"description": "团队组长获得 ALL_TOOLS(包括 spawn、send、read_inbox 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。",
"alternatives": "给所有 agent 相同的工具更简单也更平等,但实践中会导致协调混乱——多个 agent 互相管理,产生冲突的任务分配。静态的基于角色的工具过滤是可预测的,易于推理。"
},
"ja": {
"title": "チームメイトはツールのサブセット、リーダーは全ツール",
"description": "チームリーダーは ALL_TOOLS(spawn、send、read_inbox など含む)を受け取り、チームメイトは TEAMMATE_TOOLS(タスク実行に特化した縮小セット)を受け取ります。これにより明確な関心の分離が強制されます:チームメイトは作業(コーディング、テスト、調査)に集中し、リーダーは調整(タスク作成、作業割り当て、コミュニケーション管理)に集中します。"
"description": "チームリーダーは ALL_TOOLS(spawn、send、read_inbox など含む)を受け取り、チームメイトは TEAMMATE_TOOLS(タスク実行に特化した縮小セット)を受け取ります。これにより明確な関心の分離が強制されます:チームメイトは作業(コーディング、テスト、調査)に集中し、リーダーは調整(タスク作成、作業割り当て、コミュニケーション管理)に集中します。",
"alternatives": "すべてのエージェントに同一のツールを与えるのはシンプルで平等ですが、実際には調整の混乱を招きます——複数のエージェントが互いを管理しようとし、矛盾するタスク割り当てが発生します。静的なロールベースのフィルタリングは予測可能で推論しやすいです。"
}
}
]
+13 -7
View File
@@ -5,14 +5,16 @@
"id": "jsonl-inbox",
"title": "JSONL Inbox Files Instead of Shared Memory",
"description": "Each teammate has its own inbox file (a JSONL file in the team directory). Sending a message means appending a JSON line to the recipient's inbox file. Reading messages means reading the inbox file and tracking which line was last read. JSONL is append-only by nature, which means concurrent writers don't corrupt each other's data (appends to different file positions). This works across processes without any shared memory, mutex, or IPC mechanism. It's also crash-safe: if the writer crashes mid-append, the worst case is one partial line that the reader can skip.",
"alternatives": "Shared memory (Python multiprocessing.Queue) would be faster but doesn't work if agents are separate processes launched independently. A message broker (Redis, RabbitMQ) provides robust pub/sub but adds infrastructure dependencies. Unix domain sockets would work but are harder to debug (no human-readable message log). JSONL files are the simplest approach that provides persistence, cross-process communication, and debuggability.",
"alternatives": "Shared memory (Java ConcurrentLinkedQueue or BlockingQueue) would be faster but doesn't work if agents are separate processes launched independently. A message broker (Redis, RabbitMQ) provides robust pub/sub but adds infrastructure dependencies. Unix domain sockets would work but are harder to debug (no human-readable message log). JSONL files are the simplest approach that provides persistence, cross-process communication, and debuggability.",
"zh": {
"title": "JSONL 收件箱文件而非共享内存",
"description": "每个队友都有自己的收件箱文件(团队目录中的 JSONL 文件)。发送消息意味着向接收者的收件箱文件追加一行 JSON。读取消息意味着读取收件箱文件并追踪上次读到的行。JSONL 天然是仅追加的,这意味着并发写入不会破坏彼此的数据(追加到不同的文件位置)。这在无需共享内存、互斥锁或 IPC 机制的情况下跨进程工作。它也是崩溃安全的:如果写入者在追加中途崩溃,最坏情况是一行不完整的数据,读取者可以跳过。"
"description": "每个队友都有自己的收件箱文件(团队目录中的 JSONL 文件)。发送消息意味着向接收者的收件箱文件追加一行 JSON。读取消息意味着读取收件箱文件并追踪上次读到的行。JSONL 天然是仅追加的,这意味着并发写入不会破坏彼此的数据(追加到不同的文件位置)。这在无需共享内存、互斥锁或 IPC 机制的情况下跨进程工作。它也是崩溃安全的:如果写入者在追加中途崩溃,最坏情况是一行不完整的数据,读取者可以跳过。",
"alternatives": "共享内存(Java ConcurrentLinkedQueue 或 BlockingQueue)更快,但在 agent 作为独立进程启动时无法工作。消息中间件(Redis、RabbitMQ)提供可靠的发布/订阅,但增加了基础设施依赖。Unix 域套接字可以工作但更难调试(没有人类可读的消息日志)。JSONL 文件是提供持久化、跨进程通信和可调试性的最简单方案。"
},
"ja": {
"title": "共有メモリではなく JSONL インボックスファイル",
"description": "各チームメイトはチームディレクトリ内に独自のインボックスファイル(JSONL ファイル)を持ちます。メッセージの送信は受信者のインボックスファイルに JSON 行を追記することです。メッセージの読み取りはインボックスファイルを読んで最後に読んだ行を追跡することです。JSONL は本質的に追記専用で、並行ライターが互いのデータを破壊しません(異なるファイル位置への追記)。共有メモリ、ミューテックス、IPC メカニズムなしにプロセス間で動作します。"
"description": "各チームメイトはチームディレクトリ内に独自のインボックスファイル(JSONL ファイル)を持ちます。メッセージの送信は受信者のインボックスファイルに JSON 行を追記することです。メッセージの読み取りはインボックスファイルを読んで最後に読んだ行を追跡することです。JSONL は本質的に追記専用で、並行ライターが互いのデータを破壊しません(異なるファイル位置への追記)。共有メモリ、ミューテックス、IPC メカニズムなしにプロセス間で動作します。",
"alternatives": "共有メモリ(Java の ConcurrentLinkedQueue や BlockingQueue)はより高速ですが、エージェントが独立プロセスとして起動される場合は機能しません。メッセージブローカー(Redis、RabbitMQ)は堅牢なパブ/サブを提供しますが、インフラ依存が増えます。Unix ドメインソケットは機能しますがデバッグが困難です(人間可読なメッセージログがない)。JSONL ファイルは永続性、プロセス間通信、デバッグ容易性を提供する最もシンプルなアプローチです。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "A single generic message type with metadata fields would be more flexible but makes it harder to enforce protocol correctness. Many more types (10+) would provide finer-grained semantics but increase the model's decision burden. Five types is the sweet spot where every type has a clear, distinct purpose.",
"zh": {
"title": "恰好五种消息类型覆盖所有协调模式",
"description": "消息系统恰好支持五种类型:(1) message 用于两个 agent 间的点对点通信;(2) broadcast 用于全团队公告;(3) shutdown_request 用于优雅终止;(4) shutdown_response 用于确认终止;(5) plan_approval_response 用于组长批准或拒绝队友的计划。这五种类型映射到基本协调模式:直接通信、广播、生命周期管理和审批流程。"
"description": "消息系统恰好支持五种类型:(1) message 用于两个 agent 间的点对点通信;(2) broadcast 用于全团队公告;(3) shutdown_request 用于优雅终止;(4) shutdown_response 用于确认终止;(5) plan_approval_response 用于组长批准或拒绝队友的计划。这五种类型映射到基本协调模式:直接通信、广播、生命周期管理和审批流程。",
"alternatives": "单一的通用消息类型加元数据字段更灵活,但更难确保协议正确性。更多类型(10 种以上)能提供更细粒度的语义,但增加了模型的决策负担。五种类型是最佳平衡点,每种类型都有明确而独特的用途。"
},
"ja": {
"title": "正確に5つのメッセージタイプで全連携パターンをカバー",
"description": "メッセージングシステムは正確に5つのタイプをサポートします:(1) message は2つのエージェント間のポイントツーポイント通信、(2) broadcast はチーム全体への通知、(3) shutdown_request はグレースフルな終了要求、(4) shutdown_response はシャットダウンの確認応答、(5) plan_approval_response はリーダーによるチームメイトの計画の承認・却下。"
"description": "メッセージングシステムは正確に5つのタイプをサポートします:(1) message は2つのエージェント間のポイントツーポイント通信、(2) broadcast はチーム全体への通知、(3) shutdown_request はグレースフルな終了要求、(4) shutdown_response はシャットダウンの確認応答、(5) plan_approval_response はリーダーによるチームメイトの計画の承認・却下。",
"alternatives": "メタデータフィールド付きの単一汎用メッセージタイプはより柔軟ですが、プロトコルの正確性を強制するのが難しくなります。より多くのタイプ(10以上)はきめ細かなセマンティクスを提供しますが、モデルの判断負担が増えます。5つのタイプは各タイプが明確で独自の目的を持つスイートスポットです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Checking inbox after each tool execution would be more responsive but adds overhead to every tool call, which is more frequent than LLM calls. A separate watcher thread could monitor the inbox continuously but adds threading complexity. Checking once per LLM call is the pragmatic sweet spot: responsive enough for coordination, cheap enough to not impact performance.",
"zh": {
"title": "每次 LLM 调用前检查收件箱",
"description": "队友在每次 agent 循环迭代的顶部、调用 LLM API 之前检查收件箱文件。这确保了对传入消息的最大响应性:一个终止请求会在一个循环迭代内被看到(通常几秒钟),而非在当前任务完成后(可能数分钟)。收件箱检查成本很低(读取小文件,检查是否有新行),相比 LLM 调用(秒级延迟,数千 token)微不足道。这个位置还意味着传入消息可以影响下一次 LLM 调用——一条'停止 X,转去做 Y'的消息会立即生效。"
"description": "队友在每次 agent 循环迭代的顶部、调用 LLM API 之前检查收件箱文件。这确保了对传入消息的最大响应性:一个终止请求会在一个循环迭代内被看到(通常几秒钟),而非在当前任务完成后(可能数分钟)。收件箱检查成本很低(读取小文件,检查是否有新行),相比 LLM 调用(秒级延迟,数千 token)微不足道。这个位置还意味着传入消息可以影响下一次 LLM 调用——一条'停止 X,转去做 Y'的消息会立即生效。",
"alternatives": "在每次工具执行后检查收件箱响应更快,但为每次工具调用增加了开销,而工具调用比 LLM 调用更频繁。独立的监视线程可以持续监控收件箱,但增加了线程复杂度。每次 LLM 调用检查一次是务实的最佳平衡点:对协调足够响应,对性能影响足够小。"
},
"ja": {
"title": "毎回の LLM 呼び出し前にインボックスを確認",
"description": "チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。"
"description": "チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。",
"alternatives": "各ツール実行後にインボックスを確認すればより応答性が高まりますが、LLM 呼び出しより頻繁なツール呼び出しごとにオーバーヘッドが追加されます。別のウォッチャースレッドでインボックスを継続監視できますが、スレッドの複雑さが増します。LLM 呼び出しごとに1回の確認は、連携に十分な応答性とパフォーマンスへの影響の少なさを両立する実用的なスイートスポットです。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "Event-driven notification (file watchers via inotify/fsevents, or a pub/sub channel) would reduce latency from seconds to milliseconds. But file watchers are platform-specific and unreliable across network filesystems. A message broker would work but adds infrastructure. For a system where tasks take minutes to complete, discovering new tasks in 1 second instead of 10 milliseconds makes no practical difference.",
"zh": {
"title": "轮询未认领任务而非事件驱动通知",
"description": "自主队友每隔约 1 秒轮询共享任务板以寻找未认领的任务,而非等待事件驱动的通知。轮询从根本上比发布/订阅更简单:没有订阅管理、没有事件路由、没有事件丢失的 bug。在基于文件的持久化下,轮询就是'读取目录列表'——一个低成本操作,无论有多少 agent 在运行都能正常工作。1 秒的间隔平衡了响应性(新任务被快速发现)和文件系统开销(不会过度读取磁盘)。"
"description": "自主队友每隔约 1 秒轮询共享任务板以寻找未认领的任务,而非等待事件驱动的通知。轮询从根本上比发布/订阅更简单:没有订阅管理、没有事件路由、没有事件丢失的 bug。在基于文件的持久化下,轮询就是'读取目录列表'——一个低成本操作,无论有多少 agent 在运行都能正常工作。1 秒的间隔平衡了响应性(新任务被快速发现)和文件系统开销(不会过度读取磁盘)。",
"alternatives": "事件驱动通知(通过 inotify/fsevents 的文件监视器,或发布/订阅通道)可以将延迟从秒级降低到毫秒级。但文件监视器是平台特定的,在网络文件系统上不可靠。消息中间件可以工作但增加了基础设施。对于任务需要几分钟才能完成的系统,1 秒发现新任务与 10 毫秒相比没有实际差别。"
},
"ja": {
"title": "イベント駆動通知ではなくポーリングで未割り当てタスクを発見",
"description": "自律的なチームメイトはイベント駆動の通知を待つのではなく、約1秒ごとに共有タスクボードをポーリングして未割り当てタスクを探します。ポーリングはパブ/サブより根本的にシンプルです:サブスクリプション管理、イベントルーティング、イベント欠落バグがありません。ファイルベースの永続化では、ポーリングは「ディレクトリ一覧を読む」だけで、実行中のエージェント数に関係なく動作する安価な操作です。"
"description": "自律的なチームメイトはイベント駆動の通知を待つのではなく、約1秒ごとに共有タスクボードをポーリングして未割り当てタスクを探します。ポーリングはパブ/サブより根本的にシンプルです:サブスクリプション管理、イベントルーティング、イベント欠落バグがありません。ファイルベースの永続化では、ポーリングは「ディレクトリ一覧を読む」だけで、実行中のエージェント数に関係なく動作する安価な操作です。",
"alternatives": "イベント駆動通知(inotify/fsevents によるファイルウォッチャー、またはパブ/サブチャネル)はレイテンシを秒からミリ秒に削減できます。しかしファイルウォッチャーはプラットフォーム固有でネットワークファイルシステムでは信頼性が低いです。メッセージブローカーは機能しますがインフラが追加されます。タスクの完了に数分かかるシステムでは、1秒で新タスクを発見するのと10ミリ秒の差に実用的な違いはありません。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "No timeout (wait forever) risks zombie processes. A very short timeout (5s) causes premature exits when the lead is simply thinking or typing. A heartbeat system (lead periodically pings teammates to keep them alive) works but adds protocol complexity. The 60-second fixed timeout is a good default that balances false-positive exits against resource waste.",
"zh": {
"title": "空闲 60 秒后自动终止",
"description": "当自主队友没有任务可做且收件箱中没有消息时,它最多等待 60 秒后放弃并关闭。这防止了永远等待不会到来的工作的僵尸队友——这在组长忘记发送关闭请求、或所有剩余任务都被外部事件阻塞时是真实存在的问题。60 秒窗口足够长,不会因为任务完成到新任务创建之间的短暂间隔而导致过早关闭;又足够短,不会让闲置队友浪费资源。"
"description": "当自主队友没有任务可做且收件箱中没有消息时,它最多等待 60 秒后放弃并关闭。这防止了永远等待不会到来的工作的僵尸队友——这在组长忘记发送关闭请求、或所有剩余任务都被外部事件阻塞时是真实存在的问题。60 秒窗口足够长,不会因为任务完成到新任务创建之间的短暂间隔而导致过早关闭;又足够短,不会让闲置队友浪费资源。",
"alternatives": "无超时(永远等待)有僵尸进程的风险。非常短的超时(5 秒)会在组长只是在思考或打字时导致过早退出。心跳系统(组长定期 ping 队友以保持存活)可行但增加了协议复杂度。60 秒固定超时是在误报退出与资源浪费之间取得平衡的良好默认值。"
},
"ja": {
"title": "60秒のアイドルタイムアウトで自動終了",
"description": "自律的なチームメイトが作業するタスクもインボックスのメッセージもない場合、最大60秒待ってから諦めてシャットダウンします。これにより永遠に来ない仕事を待ち続けるゾンビチームメイトを防ぎます。60秒のウィンドウはタスク完了から新タスク作成までの短い間隔で早期シャットダウンが起きない十分な長さであり、かつ未使用のチームメイトがリソースを浪費しない十分な短さです。"
"description": "自律的なチームメイトが作業するタスクもインボックスのメッセージもない場合、最大60秒待ってから諦めてシャットダウンします。これにより永遠に来ない仕事を待ち続けるゾンビチームメイトを防ぎます。60秒のウィンドウはタスク完了から新タスク作成までの短い間隔で早期シャットダウンが起きない十分な長さであり、かつ未使用のチームメイトがリソースを浪費しない十分な短さです。",
"alternatives": "タイムアウトなし(永久待機)はゾンビプロセスのリスクがあります。非常に短いタイムアウト(5秒)はリーダーが考えたりタイピングしているだけの時に早期退出を引き起こします。ハートビートシステム(リーダーが定期的にチームメイトに ping して生存維持)は機能しますがプロトコルの複雑さが増します。60秒の固定タイムアウトは誤検知退出とリソース浪費のバランスを取る良いデフォルト値です。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Putting identity in the system prompt (which survives compression) would avoid this problem, but violates the cache-friendly static-system-prompt design from s05. Embedding identity in the summary prompt ('when summarizing, always include your name and team') is unreliable -- the LLM might omit it. Explicit post-compression injection is deterministic and guaranteed to work.",
"zh": {
"title": "上下文压缩后重新注入队友身份",
"description": "自动压缩对话时,生成的摘要会丢失关键元数据:队友的名称、所属团队和 agent_id。没有这些信息,队友无法认领任务(任务按名称归属)、无法检查收件箱(收件箱文件以 agent_id 为键)、也无法在消息中表明身份。因此每次自动压缩后,系统会向对话中重新注入一个结构化的身份块:'你是 [team] 团队的 [name],你的 agent_id 是 [id],你的收件箱在 [path]。'这是队友在记忆丢失后保持功能所需的最小上下文。"
"description": "自动压缩对话时,生成的摘要会丢失关键元数据:队友的名称、所属团队和 agent_id。没有这些信息,队友无法认领任务(任务按名称归属)、无法检查收件箱(收件箱文件以 agent_id 为键)、也无法在消息中表明身份。因此每次自动压缩后,系统会向对话中重新注入一个结构化的身份块:'你是 [team] 团队的 [name],你的 agent_id 是 [id],你的收件箱在 [path]。'这是队友在记忆丢失后保持功能所需的最小上下文。",
"alternatives": "将身份放在系统提示词中(在压缩后保留)可以避免此问题,但违反了 s05 中缓存友好的静态系统提示词设计。在摘要提示词中嵌入身份('摘要时始终包含你的名称和团队')不可靠——LLM 可能会省略它。显式的压缩后注入是确定性的,保证有效。"
},
"ja": {
"title": "コンテキスト圧縮後にチームメイトのアイデンティティを再注入",
"description": "自動コンパクトが会話を圧縮すると、生成された要約は重要なメタデータを失います:チームメイトの名前、所属チーム、agent_id。この情報がなければチームメイトはタスクを申告できず(タスクは名前で所有)、インボックスを確認できず(インボックスファイルは agent_id をキーとする)、メッセージで自分を識別できません。そのため自動コンパクトの後、システムは構造化されたアイデンティティブロックを会話に再注入します。これはメモリ喪失後もチームメイトが機能し続けるために必要な最小限のコンテキストです。"
"description": "自動コンパクトが会話を圧縮すると、生成された要約は重要なメタデータを失います:チームメイトの名前、所属チーム、agent_id。この情報がなければチームメイトはタスクを申告できず(タスクは名前で所有)、インボックスを確認できず(インボックスファイルは agent_id をキーとする)、メッセージで自分を識別できません。そのため自動コンパクトの後、システムは構造化されたアイデンティティブロックを会話に再注入します。これはメモリ喪失後もチームメイトが機能し続けるために必要な最小限のコンテキストです。",
"alternatives": "アイデンティティをシステムプロンプトに含める(圧縮後も保持される)ことでこの問題を回避できますが、s05 のキャッシュフレンドリーな静的システムプロンプト設計に違反します。要約プロンプトにアイデンティティを埋め込む(「要約時は必ず名前とチームを含める」)のは信頼性が低く、LLM が省略する可能性があります。明示的な圧縮後注入は決定論的で確実に機能します。"
}
}
]
+24 -12
View File
@@ -8,11 +8,13 @@
"alternatives": "A single shared workspace is simpler but causes edit collisions and mixed git state. Fully independent task stores per lane avoid collisions but lose team-level visibility and make planning harder.",
"zh": {
"title": "共享任务板 + 隔离执行通道",
"description": "任务板继续集中在 `.tasks/`,而文件改动发生在按任务划分的 worktree 目录中。这样既保留了全局可见性(谁在做什么、完成到哪),又避免所有人同时写同一目录导致冲突。协调层简单(一个任务板),执行层安全(多条隔离通道)。"
"description": "任务板继续集中在 `.tasks/`,而文件改动发生在按任务划分的 worktree 目录中。这样既保留了全局可见性(谁在做什么、完成到哪),又避免所有人同时写同一目录导致冲突。协调层简单(一个任务板),执行层安全(多条隔离通道)。",
"alternatives": "单一共享工作区更简单,但会导致编辑冲突和混乱的 git 状态。每条通道完全独立的任务存储可以避免冲突,但失去了团队级可见性,也使规划更加困难。"
},
"ja": {
"title": "共有タスクボード + 分離実行レーン",
"description": "タスクボードは `.tasks/` に集約しつつ、実際の編集はタスクごとの worktree ディレクトリで行う。これにより全体の可視性(担当と進捗)を維持しながら、単一ディレクトリでの衝突を回避できる。調整は1つのボードで単純化され、実行はレーン分離で安全になる。"
"description": "タスクボードは `.tasks/` に集約しつつ、実際の編集はタスクごとの worktree ディレクトリで行う。これにより全体の可視性(担当と進捗)を維持しながら、単一ディレクトリでの衝突を回避できる。調整は1つのボードで単純化され、実行はレーン分離で安全になる。",
"alternatives": "単一の共有ワークスペースはシンプルですが、編集の衝突や git 状態の混在を引き起こします。レーンごとに完全独立のタスクストアは衝突を回避しますが、チームレベルの可視性が失われ、計画が困難になります。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Relying only on `git worktree list` removes local bookkeeping but loses task binding metadata and custom lifecycle states. Keeping all state only in memory is simpler in code but breaks recoverability.",
"zh": {
"title": "显式 worktree 生命周期索引",
"description": "`.worktrees/index.json` 记录每个 worktree 的名称、路径、分支、task_id 与状态。即使上下文压缩或进程重启,这些生命周期状态仍可检查和恢复。它也为 list/status/remove 提供了确定性的本地数据源。"
"description": "`.worktrees/index.json` 记录每个 worktree 的名称、路径、分支、task_id 与状态。即使上下文压缩或进程重启,这些生命周期状态仍可检查和恢复。它也为 list/status/remove 提供了确定性的本地数据源。",
"alternatives": "仅依赖 `git worktree list` 可以省去本地记录,但会丢失任务绑定的元数据和自定义生命周期状态。仅在内存中保持所有状态在代码上更简单,但破坏了可恢复性。"
},
"ja": {
"title": "明示的な worktree ライフサイクル索引",
"description": "`.worktrees/index.json` に name/path/branch/task_id/status を記録することで、コンテキスト圧縮やプロセス再起動後も状態を追跡できる。list/status/remove の挙動もこの索引を基準に決定できる。"
"description": "`.worktrees/index.json` に name/path/branch/task_id/status を記録することで、コンテキスト圧縮やプロセス再起動後も状態を追跡できる。list/status/remove の挙動もこの索引を基準に決定できる。",
"alternatives": "`git worktree list` のみに依存すればローカルの記録管理は不要になりますが、タスク紐付けメタデータやカスタムライフサイクル状態が失われます。すべての状態をメモリのみに保持するのはコード上シンプルですが、回復性が損なわれます。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Global cwd mutation is easy to implement but can leak context across parallel work. Allowing silent re-entry makes lifecycle ownership ambiguous and complicates teardown behavior.",
"zh": {
"title": "按通道 cwd 路由 + 禁止重入",
"description": "命令通过 `worktree_run(name, command)` 使用 `cwd` 参数路由到 worktree 目录。重入保护避免了在已激活的 worktree 上下文中意外二次进入,保持生命周期归属清晰。"
"description": "命令通过 `worktree_run(name, command)` 使用 `cwd` 参数路由到 worktree 目录。重入保护避免了在已激活的 worktree 上下文中意外二次进入,保持生命周期归属清晰。",
"alternatives": "全局 cwd 变更容易实现,但可能在并行工作间泄漏上下文。允许静默重入会使生命周期归属模糊,并使清理行为复杂化。"
},
"ja": {
"title": "レーン単位 cwd ルーティング + 再入防止",
"description": "`worktree_run(name, command)` で `cwd` パラメータを使いコマンドを worktree ディレクトリへ転送する。再入ガードにより active な worktree への二重入場を防ぎ、ライフサイクルの帰属を明確に保つ。"
"description": "`worktree_run(name, command)` で `cwd` パラメータを使いコマンドを worktree ディレクトリへ転送する。再入ガードにより active な worktree への二重入場を防ぎ、ライフサイクルの帰属を明確に保つ。",
"alternatives": "グローバル cwd の変更は実装が簡単ですが、並行作業間でコンテキストが漏洩する可能性があります。サイレントな再入を許可するとライフサイクルの帰属が曖昧になり、ティアダウン動作が複雑になります。"
}
},
{
@@ -50,11 +56,13 @@
"alternatives": "Relying only on console logs is lighter but fragile during long sessions and hard to audit. A full event bus infrastructure is powerful but heavier than needed for this teaching baseline.",
"zh": {
"title": "追加式生命周期事件流",
"description": "生命周期事件写入 `.worktrees/events.jsonl`(如 `worktree.create.*`、`worktree.remove.*`、`task.completed`)。这样状态迁移可查询、可追踪,失败也会以 `*.failed` 显式暴露,而不是静默丢失。"
"description": "生命周期事件写入 `.worktrees/events.jsonl`(如 `worktree.create.*`、`worktree.remove.*`、`task.completed`)。这样状态迁移可查询、可追踪,失败也会以 `*.failed` 显式暴露,而不是静默丢失。",
"alternatives": "仅依赖控制台日志更轻量,但在长时间会话中脆弱且难以审计。完整的事件总线基础设施功能强大,但对这个教学基线来说过于重量级。"
},
"ja": {
"title": "追記型ライフサイクルイベント",
"description": "ライフサイクルイベントを `.worktrees/events.jsonl` に追記する(`worktree.create.*`、`worktree.remove.*`、`task.completed` など)。遷移が可観測になり、失敗も `*.failed` として明示できる。"
"description": "ライフサイクルイベントを `.worktrees/events.jsonl` に追記する(`worktree.create.*`、`worktree.remove.*`、`task.completed` など)。遷移が可観測になり、失敗も `*.failed` として明示できる。",
"alternatives": "コンソールログのみに依存する方が軽量ですが、長時間セッションでは脆弱で監査が困難です。完全なイベントバスインフラは強力ですが、この教育用ベースラインには過剰です。"
}
},
{
@@ -64,11 +72,13 @@
"alternatives": "Keeping closeout fully manual gives flexibility but increases operational drift. Fully automatic removal on every completion risks deleting a workspace before final review.",
"zh": {
"title": "任务与工作区一起收尾",
"description": "`worktree_remove(..., complete_task=true)` 允许在一个动作里完成收尾:删除隔离目录并把绑定任务标记为 completed。收尾保持为显式工具驱动迁移(`worktree_keep` / `worktree_remove`),而不是隐藏的自动清理。这样可减少状态悬挂(任务已完成但临时工作区仍活跃,或反过来)。"
"description": "`worktree_remove(..., complete_task=true)` 允许在一个动作里完成收尾:删除隔离目录并把绑定任务标记为 completed。收尾保持为显式工具驱动迁移(`worktree_keep` / `worktree_remove`),而不是隐藏的自动清理。这样可减少状态悬挂(任务已完成但临时工作区仍活跃,或反过来)。",
"alternatives": "完全手动收尾提供了灵活性,但增加了操作偏差。每次完成时自动删除有在最终审查前删除工作区的风险。"
},
"ja": {
"title": "タスクとワークスペースを同時にクローズ",
"description": "`worktree_remove(..., complete_task=true)` により、分離ディレクトリ削除とタスク完了更新を1ステップで実行できる。クローズ処理は `worktree_keep` / `worktree_remove` の明示ツール遷移として扱い、暗黙の自動清掃にはしない。"
"description": "`worktree_remove(..., complete_task=true)` により、分離ディレクトリ削除とタスク完了更新を1ステップで実行できる。クローズ処理は `worktree_keep` / `worktree_remove` の明示ツール遷移として扱い、暗黙の自動清掃にはしない。",
"alternatives": "完全手動のクローズアウトは柔軟性を提供しますが、運用上のドリフトが増加します。完了ごとの完全自動削除は最終レビュー前にワークスペースを削除するリスクがあります。"
}
},
{
@@ -78,11 +88,13 @@
"alternatives": "Using logs alone hides structured transitions; using events as the only state source risks drift when replay/repair semantics are undefined.",
"zh": {
"title": "事件流是观测旁路,不是状态机替身",
"description": "生命周期事件提升可审计性,但真实状态源仍是任务/工作区状态文件。事件更适合做迁移轨迹,而不是替代主状态机。"
"description": "生命周期事件提升可审计性,但真实状态源仍是任务/工作区状态文件。事件更适合做迁移轨迹,而不是替代主状态机。",
"alternatives": "仅使用日志会隐藏结构化的状态迁移;将事件作为唯一的状态源在重放/修复语义未定义时存在漂移风险。"
},
"ja": {
"title": "イベントは観測サイドチャネルであり状態機械の代替ではない",
"description": "ライフサイクルイベントは監査性を高めるが、真の状態源は task/worktree 状態ファイルのまま。イベントは遷移トレースとして扱い、主状態機械の代替にしない。"
"description": "ライフサイクルイベントは監査性を高めるが、真の状態源は task/worktree 状態ファイルのまま。イベントは遷移トレースとして扱い、主状態機械の代替にしない。",
"alternatives": "ログだけでは構造化された遷移が隠れます。イベントを唯一の状態ソースとして使用すると、リプレイ/修復のセマンティクスが未定義の場合にドリフトのリスクがあります。"
}
}
]
+48 -48
View File
@@ -3,216 +3,216 @@
"version": "s01",
"locale": "en",
"title": "s01: The Agent Loop",
"content": "# s01: The Agent Loop\n\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"One loop & Bash is all you need\"* -- one tool + one loop = an agent.\n\n## Problem\n\nA language model can reason about code, but it can't *touch* the real world -- can't read files, run tests, or check errors. Without a loop, every tool call requires you to manually copy-paste results back. You become the loop.\n\n## Solution\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\nOne exit condition controls the entire flow. The loop runs until the model stops calling tools.\n\n## How It Works\n\n1. User prompt becomes the first message.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. Send messages + tool definitions to the LLM.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. Execute each tool call, collect results, append as a user message. Loop back to step 2.\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\nAssembled into one function:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\nThat's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop.\n\n## What Changed\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n4. `Create a directory called test_output and write 3 files in it`\n"
"content": "# s01: The Agent Loop\r\n\r\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"One loop & Bash is all you need\"* -- one tool + one loop = an agent.\r\n>\r\n> **Harness layer**: The loop -- the model's first connection to the real world.\r\n\r\n## Problem\r\n\r\nA language model can reason about code, but it can't *touch* the real world -- can't read files, run tests, or check errors. Without a loop, every tool call requires you to manually copy-paste results back. You become the loop.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tool |\r\n| prompt | | | | execute |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n (ChatClient.call() auto-loops until no tool calls)\r\n```\r\n\r\nA single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.\r\n\r\n## How It Works\r\n\r\n### 1. Build ChatClient: Inject Model + Register Tools\r\n\r\nInject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.\r\n\r\n```java\r\n// TIP: The Python version creates client = Anthropic() and MODEL at module level.\r\n// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.\r\npublic S01AgentLoop(ChatModel chatModel) {\r\n this.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent at \" + System.getProperty(\"user.dir\")\r\n + \". Use bash to solve tasks. Act, don't explain.\")\r\n .defaultTools(new BashTool()) // Tool object with @Tool annotation\r\n .build();\r\n}\r\n```\r\n\r\n### 2. `@Tool` Annotation: Declarative Tool Registration\r\n\r\nSpring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.\r\n\r\n```java\r\n// BashTool -- corresponds to the Python version's run_bash() function\r\npublic class BashTool {\r\n @Tool(description = \"Run a shell command and return stdout + stderr\")\r\n public String bash(@ToolParam(description = \"The shell command to execute\")\r\n String command) {\r\n // Dangerous command check + ProcessBuilder execution + timeout control + output truncation\r\n // ...\r\n }\r\n}\r\n```\r\n\r\n> Comparison with Python's manual registration:\r\n> - Python: `TOOLS = [{\"name\": \"bash\", \"input_schema\": {...}}]` + `TOOL_HANDLERS = {\"bash\": run_bash}`\r\n> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods\r\n\r\n### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood\r\n\r\n**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:\r\n\r\n```python\r\n# Python version -- manual loop\r\ndef agent_loop(messages):\r\n while True:\r\n response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)\r\n # Collect assistant message\r\n messages.append({\"role\": \"assistant\", \"content\": response.content})\r\n if response.stop_reason != \"tool_use\":\r\n return response # Model no longer calling tools, exit loop\r\n # Execute tools and feed back results\r\n for block in response.content:\r\n if block.type == \"tool_use\":\r\n result = TOOL_HANDLERS[block.name](block.input)\r\n messages.append({\"role\": \"user\", \"content\": [{\"type\": \"tool_result\", ...}]})\r\n```\r\n\r\nSpring AI's `ChatClient.call()` **encapsulates fully equivalent logic internally**:\r\n\r\n```\r\ncall() internal flow:\r\n ┌─────────────────────────────────────────────────────┐\r\n │ 1. Assemble request: system prompt + user msg + tools │\r\n │ 2. Send to LLM │\r\n │ 3. Parse response │\r\n │ ├── Has tool_use? ──→ Yes: │\r\n │ │ a. Extract tool name and arguments │\r\n │ │ b. Invoke corresponding @Tool method via reflection │\r\n │ │ c. Append tool_result to message list │\r\n │ │ d. Go back to step 2 (auto-loop) │\r\n │ └── No ──→ Return final text Line truncated
},
{
"version": "s02",
"locale": "en",
"title": "s02: Tool Use",
"content": "# s02: Tool Use\n\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Adding a tool means adding one handler\"* -- the loop stays the same; new tools register into the dispatch map.\n\n## Problem\n\nWith only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools like `read_file` and `write_file` let you enforce path sandboxing at the tool level.\n\nThe key insight: adding tools does not require changing the loop.\n\n## Solution\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## How It Works\n\n1. Each tool gets a handler function. Path sandboxing prevents workspace escape.\n\n```python\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. The dispatch map links tool names to handlers.\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. In the loop, look up the handler by name. The loop body itself is unchanged from s01.\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\nAdd a tool = add a handler + add a schema entry. The loop never changes.\n\n## What Changed From s01\n\n| Component | Before (s01) | After (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (bash only) | 4 (bash, read, write, edit)|\n| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |\n| Path safety | None | `safe_path()` sandbox |\n| Agent loop | Unchanged | Unchanged |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
"content": "# s02: Tool Use\r\n\r\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Adding a tool means adding one @Tool method\"* -- the loop stays the same; new tools are passed into `defaultTools()`.\r\n>\r\n> **Harness layer**: Tool dispatch -- expanding what the model can reach.\r\n\r\n## Problem\r\n\r\nWith only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools (`read_file`, `write_file`) let you enforce path sandboxing at the tool level.\r\n\r\nThe key insight: adding tools does not require changing the loop.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +--------------------+\r\n| User | ---> | LLM | ---> | defaultTools() |\r\n| prompt | | | | { |\r\n+--------+ +---+---+ | BashTool |\r\n ^ | ReadFileTool |\r\n | | WriteFileTool |\r\n +-----------+ EditFileTool |\r\n tool_result | } |\r\n +--------------------+\r\n\r\nSpring AI auto-registers and dispatches via @Tool annotations.\r\nNo hand-written dispatch map needed -- the framework scans annotated methods on tool objects.\r\n```\r\n\r\n## How It Works\r\n\r\n1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.\r\n\r\n```java\r\n// PathValidator -- corresponds to the Python version's safe_path() function\r\npublic class PathValidator {\r\n private final Path workDir;\r\n\r\n public Path resolve(String relativePath) {\r\n Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();\r\n if (!resolved.startsWith(workDir)) {\r\n throw new IllegalArgumentException(\"Path escapes workspace: \" + relativePath);\r\n }\r\n return resolved;\r\n }\r\n}\r\n\r\n// ReadFileTool -- corresponds to the Python version's run_read() function\r\npublic class ReadFileTool {\r\n private final PathValidator pathValidator;\r\n\r\n @Tool(description = \"Read file contents. Optionally limit the number of lines returned.\")\r\n public String readFile(\r\n @ToolParam(description = \"Relative path to the file\") String path,\r\n @ToolParam(description = \"Maximum number of lines to read\", required = false) Integer limit) {\r\n Path filePath = pathValidator.resolve(path);\r\n List<String> lines = Files.readAllLines(filePath);\r\n if (limit != null && limit > 0 && limit < lines.size()) {\r\n lines = lines.subList(0, limit);\r\n }\r\n return String.join(\"\\n\", lines);\r\n }\r\n}\r\n```\r\n\r\n2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.\r\n\r\n```java\r\n// Corresponds to the Python version's TOOL_HANDLERS dict\r\n// Python: TOOL_HANDLERS = {\"bash\": fn, \"read_file\": fn, \"write_file\": fn, \"edit_file\": fn}\r\n// Java: Just pass tool objects; @Tool annotations handle auto-registration\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent ...\")\r\n .defaultTools(\r\n new BashTool(), // bash command execution\r\n new ReadFileTool(), // file reading\r\n new WriteFileTool(), // file writing\r\n new EditFileTool() // file editing (find & replace)\r\n )\r\n .build();\r\n```\r\n\r\n3. The calling code is identical to s01. The loop is managed by the framework; developers only focus on tool implementation.\r\n\r\n```java\r\n// Compared to s01, the only change is that defaultTools() receives 3 more tool objects\r\n// The loop code is exactly the same -- this is the core insight of s02\r\nAgentRunner.interactive(\"s02\", userMessage ->\r\n chatClient.prompt()\r\n .user(userMessage)\r\n .call()\r\n .content()\r\n);\r\n```\r\n\r\nAdd a tool = add a `@Tool` class + pass it to `defaultTools()`. The loop never changes.\r\n\r\n> **TIPS — Key Python → Java Adaptations:**\r\n> - Python's `TOOL_HANDLERS` dict → Spring AI `@Tool` annotation + `defaultTools()` auto-registration and dispatch\r\n> - Python's `safe_path()` function → `PathValidator` class (same path escape check logic)\r\n> - Python's `lambda **kw` parameter unpacking → `@ToolParam` annotation auto-binds parameters\r\n> - Python's `block.type == \"tool_use\"` check → Spring AI handles detection and dispatch internally\r\n\r\n## What Changed From s01\r\n\r\n| Component | Before (s01) | After (s02) |\r\n|----------------|-----Line truncated
},
{
"version": "s03",
"locale": "en",
"title": "s03: TodoWrite",
"content": "# s03: TodoWrite\n\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"An agent without a plan drifts\"* -- list the steps first, then execute.\n\n## Problem\n\nOn multi-step tasks, the model loses track. It repeats work, skips steps, or wanders off. Long conversations make this worse -- the system prompt fades as tool results fill the context. A 10-step refactoring might complete steps 1-3, then the model starts improvising because it forgot steps 4-10.\n\n## Solution\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## How It Works\n\n1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. The `todo` tool goes into the dispatch map like any other tool.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`.\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\nThe \"one in_progress at a time\" constraint forces sequential focus. The nag reminder creates accountability.\n\n## What Changed From s02\n\n| Component | Before (s02) | After (s03) |\n|----------------|------------------|----------------------------|\n| Tools | 4 | 5 (+todo) |\n| Planning | None | TodoManager with statuses |\n| Nag injection | None | `<reminder>` after 3 rounds|\n| Agent loop | Simple dispatch | + rounds_since_todo counter|\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
"content": "# s03: TodoWrite\r\n\r\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"An agent without a plan drifts\"* -- list the steps first, then execute. Doubles the completion rate.\r\n>\r\n> **Harness layer**: Planning -- keeping the model on course without scripting the route.\r\n\r\n## Problem\r\n\r\nOn multi-step tasks, the model loses track -- repeats work, skips steps, or wanders off. Long conversations make this worse: tool results keep filling the context, gradually diluting the system prompt's influence. A 10-step refactoring might complete steps 1-3, then the model starts improvising because steps 4-10 have been pushed out of attention.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tools |\r\n| prompt | | | | + todo |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n |\r\n +-----------+-----------+\r\n | TodoManager state |\r\n | [ ] task A |\r\n | [>] task B <- doing |\r\n | [x] task C |\r\n +-----------------------+\r\n |\r\n Inject latest todo state into\r\n system prompt via defaultSystem()\r\n on each request\r\n```\r\n\r\n## How It Works\r\n\r\n1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.\r\n\r\n```java\r\npublic class TodoManager {\r\n\r\n public record TodoItem(String id, String text, String status) {}\r\n\r\n private List<TodoItem> items = new ArrayList<>();\r\n\r\n @Tool(description = \"Update the full task list to track progress. \"\r\n + \"Each item must have id, text, status (pending/in_progress/completed). \"\r\n + \"Only one task can be in_progress at a time. Max 20 items.\")\r\n public String updateTodos(\r\n @ToolParam(description = \"The complete list of todo items\")\r\n List<TodoItem> items) {\r\n if (items.size() > 20) return \"Error: Max 20 todos allowed\";\r\n List<TodoItem> validated = new ArrayList<>();\r\n int inProgressCount = 0;\r\n for (TodoItem item : items) {\r\n String status = (item.status() != null)\r\n ? item.status().toLowerCase() : \"pending\";\r\n if (\"in_progress\".equals(status)) inProgressCount++;\r\n validated.add(new TodoItem(item.id(), item.text().trim(), status));\r\n }\r\n if (inProgressCount > 1)\r\n return \"Error: Only one task can be in_progress at a time\";\r\n this.items = validated;\r\n return render();\r\n }\r\n}\r\n```\r\n\r\n2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.\r\n\r\n```java\r\nChatClient chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(system)\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n todoManager // @Tool annotated method auto-registered\r\n )\r\n .build();\r\n```\r\n\r\n3. System prompt injection: on each user input, inject the latest todo state into the system prompt with emphasis on update instructions.\r\n\r\n```java\r\n// Dynamic system prompt: includes current todo state\r\nString system = \"You are a coding agent at \" + workDir + \".\\n\"\r\n + \"Use the todo tool to plan multi-step tasks. \"\r\n + \"Mark in_progress before starting, completed when done.\\n\"\r\n + \"IMPORTANT: You MUST call updateTodos regularly.\\n\\n\"\r\n + \"<current-todos>\\n\" + todoManager.render() + \"\\n</current-todos>\";\r\n```\r\n\r\nThe \"only one in_progress at a time\" constraint forces sequential focus. Continuously injecting todo state into the system prompt creates accountability pressure -- the model sees its own plan every turn and won't forget to update it.\r\n\r\n> **TIP**: The Python version tracks `rounds_since_todo` inside the tool loop and injects `<reminder>` text after 3 consecutive rounds without a todo call. Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop injection, so system prompt injection is used instead to achieve the same effect.\r\n\r\n## What Changed From s02\r\n\r\n| Component | Before (s02) | After (s03) |\r\n|----------------|------------------|--------------------------------------|\r\n| Tools | 4 | 5 (+TodoManager `@Tool`) |\r\n| Planning | None | TodoManager with statuses |\r\n| State injection| None | SystLine truncated
},
{
"version": "s04",
"locale": "en",
"title": "s04: Subagents",
"content": "# s04: Subagents\n\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Break big tasks down; each subtask gets a clean context\"* -- subagents use independent messages[], keeping the main conversation clean.\n\n## Problem\n\nAs the agent works, its messages array grows. Every file read, every bash output stays in context permanently. \"What testing framework does this project use?\" might require reading 5 files, but the parent only needs the answer: \"pytest.\"\n\n## Solution\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## How It Works\n\n1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning).\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent.\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\nThe child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`.\n\n## What Changed From s03\n\n| Component | Before (s03) | After (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (base) + task (parent) |\n| Context | Single shared | Parent + child isolation |\n| Subagent | None | `run_subagent()` function |\n| Return value | N/A | Summary text only |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
"content": "# s04: Subagents\r\n\r\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Break big tasks down; each subtask gets a clean context\"* -- subagents use independent messages[], keeping the main conversation clean.\r\n>\r\n> **Harness layer**: Context isolation -- protecting the model's clarity of thought.\r\n\r\n## Problem\r\n\r\nAs the agent works, its messages array grows. Every file read, every bash output stays in context permanently. \"What testing framework does this project use?\" might require reading 5 files, but the parent only needs one word: \"pytest.\"\r\n\r\n## Solution\r\n\r\n```\r\nParent agent Subagent\r\n+------------------+ +------------------+\r\n| messages=[...] | | messages=[] | <-- fresh\r\n| | dispatch | |\r\n| tool: task | ----------> | while tool_use: |\r\n| prompt=\"...\" | | call tools |\r\n| | summary | append results |\r\n| result = \"...\" | <---------- | return last text |\r\n+------------------+ +------------------+\r\n\r\nParent context stays clean. Subagent context is discarded.\r\n```\r\n\r\n## How It Works\r\n\r\n1. The parent agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).\r\n\r\n```java\r\n// Parent Agent: has base tools + SubagentTool\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent. \"\r\n + \"Use the task tool to delegate subtasks.\")\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n new SubagentTool(chatModel) // Parent Agent exclusive\r\n )\r\n .build();\r\n```\r\n\r\n2. The subagent starts with a brand new `ChatClient` and an independent context. Only the final text returns to the parent.\r\n\r\n```java\r\n@Tool(description = \"Spawn a subagent with fresh context. \"\r\n + \"Use for exploration or subtasks that might pollute the main context.\")\r\npublic String task(\r\n @ToolParam(description = \"The task prompt\") String prompt,\r\n @ToolParam(description = \"Short description\", required = false)\r\n String description) {\r\n\r\n // Create a brand new ChatClient -- this IS \"context isolation\"\r\n ChatClient subClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding subagent. \"\r\n + \"Complete the task, then summarize findings.\")\r\n .defaultTools( // Base tools, no task (prevents recursion)\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool()\r\n )\r\n .build();\r\n\r\n String result = subClient.prompt()\r\n .user(prompt)\r\n .call()\r\n .content();\r\n\r\n // Only the final text is returned; subagent context is discarded\r\n return (result != null) ? result : \"(no summary)\";\r\n}\r\n```\r\n\r\nThe subagent may have run multiple tool calls, but its entire message history is discarded. The parent receives only a summary text, returned as a normal `tool_result`. Spring AI's `ChatClient.call()` manages the tool loop internally -- no need to manually limit iteration count.\r\n\r\n## What Changed From s03\r\n\r\n| Component | Before (s03) | After (s04) |\r\n|----------------|------------------|---------------------------------------|\r\n| Tools | 5 | 5 (base) + SubagentTool (parent only) |\r\n| Context | Single shared | Parent + child isolation (independent ChatClient) |\r\n| Subagent | None | `SubagentTool.task()` method |\r\n| Return value | N/A | Summary text only |\r\n\r\n## Try It\r\n\r\n```sh\r\ncd learn-claude-code\r\nmvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent\r\n```\r\n\r\nTry these prompts (English prompts work better with LLMs, but Chinese also works):\r\n\r\n1. `Use a subtask to find what testing framework this project uses`\r\n2. `Delegate: read all .java files and summarize what each one does`\r\n3. `Use a task to create a new module, then verify it from here`\r\n"
},
{
"version": "s05",
"locale": "en",
"title": "s05: Skills",
"content": "# s05: Skills\n\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Load knowledge when you need it, not upfront\"* -- inject via tool_result, not the system prompt.\n\n## Problem\n\nYou want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens on unused skills. 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.\n\n## Solution\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\nLayer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).\n\n## How It Works\n\n1. Each skill is a directory containing a `SKILL.md` with YAML frontmatter.\n\n```\nskills/\n pdf/\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\n code-review/\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\n```\n\n2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier.\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.rglob(\"SKILL.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n name = meta.get(\"name\", f.parent.name)\n self.skills[name] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. Layer 1 goes into the system prompt. Layer 2 is just another tool handler.\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\nThe model learns what skills exist (cheap) and loads them when relevant (expensive).\n\n## What Changed From s04\n\n| Component | Before (s04) | After (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (base + task) | 5 (base + load_skill) |\n| System prompt | Static string | + skill descriptions |\n| Knowledge | None | skills/\\*/SKILL.md files |\n| Injection | None | Two-layer (system + result)|\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
"content": "# s05: Skills\r\n\r\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Load knowledge when you need it, not upfront\"* -- inject via tool_result, not the system prompt.\r\n>\r\n> **Harness layer**: On-demand knowledge -- domain expertise, loaded when the model asks.\r\n\r\n## Problem\r\n\r\nYou want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens -- 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.\r\n\r\n## Solution\r\n\r\n```\r\nSystem prompt (Layer 1 -- always present):\r\n+--------------------------------------+\r\n| You are a coding agent. |\r\n| Skills available: |\r\n| - git: Git workflow helpers | ~100 tokens/skill\r\n| - test: Testing best practices |\r\n+--------------------------------------+\r\n\r\nWhen model calls load_skill(\"git\"):\r\n+--------------------------------------+\r\n| tool_result (Layer 2 -- on demand): |\r\n| <skill name=\"git\"> |\r\n| Full git workflow instructions... | ~2000 tokens\r\n| Step 1: ... |\r\n| </skill> |\r\n+--------------------------------------+\r\n```\r\n\r\nLayer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).\r\n\r\n## How It Works\r\n\r\n1. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.\r\n\r\n```\r\nskills/\r\n pdf/\r\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\r\n code-review/\r\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\r\n```\r\n\r\n2. SkillLoader recursively scans for `SKILL.md` files, using the directory name as the skill identifier.\r\n\r\n```java\r\npublic class SkillLoader {\r\n\r\n private static final Pattern FRONTMATTER_PATTERN =\r\n Pattern.compile(\"^---\\\\n(.*?)\\\\n---\\\\n(.*)\", Pattern.DOTALL);\r\n\r\n private final Map<String, SkillInfo> skills = new LinkedHashMap<>();\r\n\r\n record SkillInfo(Map<String, String> meta, String body, String path) {}\r\n\r\n public SkillLoader(Path skillsDir) {\r\n loadAll(skillsDir);\r\n }\r\n\r\n /** Recursively scan all SKILL.md files under the skills directory */\r\n private void loadAll(Path skillsDir) {\r\n if (!Files.exists(skillsDir)) return;\r\n try (Stream<Path> paths = Files.walk(skillsDir)) {\r\n paths.filter(p -> p.getFileName().toString().equals(\"SKILL.md\"))\r\n .sorted()\r\n .forEach(p -> {\r\n String text = Files.readString(p);\r\n var parsed = parseFrontmatter(text);\r\n String name = parsed.meta().getOrDefault(\"name\",\r\n p.getParent().getFileName().toString());\r\n skills.put(name, new SkillInfo(\r\n parsed.meta(), parsed.body(), p.toString()));\r\n });\r\n }\r\n }\r\n\r\n /** Layer 1: Get short descriptions of all skills (for system prompt injection) */\r\n public String getDescriptions() {\r\n if (skills.isEmpty()) return \"(no skills available)\";\r\n StringBuilder sb = new StringBuilder();\r\n for (var entry : skills.entrySet()) {\r\n String desc = entry.getValue().meta()\r\n .getOrDefault(\"description\", \"No description\");\r\n sb.append(\" - \").append(entry.getKey())\r\n .append(\": \").append(desc).append(\"\\n\");\r\n }\r\n return sb.toString().stripTrailing();\r\n }\r\n\r\n /** Layer 2: Load full content of a specified skill (as @Tool method) */\r\n @Tool(description = \"Load specialized knowledge by name.\")\r\n public String loadSkill(\r\n @ToolParam(description = \"Skill name to load\") String name) {\r\n SkillInfo skill = skills.get(name);\r\n if (skill == null)\r\n return \"Error: Unknown skill '\" + name + \"'. Available: \"\r\n + String.join(\", \", skills.keySet());\r\n return \"<skill name=\\\"\" + name + \"\\\">\\n\"\r\n + skill.body() + \"\\n</skill>\";\r\n }\r\n}\r\n```\r\n\r\n3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.\r\n\r\n```java\r\npublic S05SkillLoading(ChatModel chatModel) {\r\n Path skillsDir = Path.of(System.getProperty(\"user.dir\"), \"skills\");\r\n SkillLoader skillLoader = new SkillLoader(skillsDir);\r\n\r\n // Layer 1: Skill metadata injected into system prompt\r\n String system = \"You are a coding agent at \" + System.getProperty(\"user.dir\") + \".\\n\"\r\n + \"Use loadSkill to access speLine truncated
},
{
"version": "s06",
"locale": "en",
"title": "s06: Context Compact",
"content": "# s06: Context Compact\n\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Context will fill up; you need a way to make room\"* -- three-layer compression strategy for infinite sessions.\n\n## Problem\n\nThe context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens. After reading 30 files and running 20 bash commands, you hit 100,000+ tokens. The agent cannot work on large codebases without compression.\n\n## Solution\n\nThree layers, increasing in aggressiveness:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## How It Works\n\n1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders.\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **Layer 2 -- auto_compact**: When tokens exceed threshold, save full transcript to disk, then ask the LLM to summarize.\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **Layer 3 -- manual compact**: The `compact` tool triggers the same summarization on demand.\n\n4. The loop integrates all three:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\nTranscripts preserve full history on disk. Nothing is truly lost -- just moved out of active context.\n\n## What Changed From s05\n\n| Component | Before (s05) | After (s06) |\n|----------------|------------------|----------------------------|\n| Tools | 5 | 5 (base + compact) |\n| Context mgmt | None | Three-layer compression |\n| Micro-compact | None | Old results -> placeholders|\n| Auto-compact | None | Token threshold trigger |\n| Transcripts | None | Saved to .transcripts/ |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
"content": "# s06: Context Compact\r\n\r\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Context will fill up; you need a way to make room\"* -- three-layer compression strategy for infinite sessions.\r\n>\r\n> **Harness layer**: Compression -- clean memory for infinite sessions.\r\n\r\n## Problem\r\n\r\nThe context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens; after reading 30 files and running 20 commands, you easily blow past 100k tokens. Without compression, the agent simply cannot work on large codebases.\r\n\r\n## Solution\r\n\r\nThree layers, increasing in aggressiveness:\r\n\r\n```\r\nEvery turn:\r\n+------------------+\r\n| Tool call result |\r\n+------------------+\r\n |\r\n v\r\n[Layer 1: micro_compact] (silent, every turn)\r\n Replace tool_result > 3 turns old\r\n with \"[Previous: used {tool_name}]\"\r\n |\r\n v\r\n[Check: tokens > 50000?]\r\n | |\r\n no yes\r\n | |\r\n v v\r\ncontinue [Layer 2: auto_compact]\r\n Save transcript to .transcripts/\r\n LLM summarizes conversation.\r\n Replace all messages with [summary].\r\n |\r\n v\r\n [Layer 3: compact tool]\r\n Model calls compact explicitly.\r\n Same summarization as auto_compact.\r\n```\r\n\r\n## How It Works\r\n\r\n1. **Layer 1 -- Context window management**: Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop compression injection. The Java version achieves an equivalent effect by limiting the number of conversation turns injected into the system prompt (keeping only the most recent N turns) and truncating content.\r\n\r\n```java\r\n/** Estimate token count: rough estimate of 4 chars ≈ 1 token */\r\npublic int estimateTokens() {\r\n int chars = history.stream().mapToInt(t -> t.content().length()).sum();\r\n return chars / 4;\r\n}\r\n\r\n/** Get conversation history summary (for system prompt injection, keeping only recent turns) */\r\npublic String getContextSummary() {\r\n if (history.isEmpty()) return \"\";\r\n StringBuilder sb = new StringBuilder(\"\\n<conversation-context>\\n\");\r\n int start = Math.max(0, history.size() - KEEP_RECENT * 2);\r\n for (int i = start; i < history.size(); i++) {\r\n ConversationTurn turn = history.get(i);\r\n sb.append(\"[\").append(turn.role()).append(\"]: \")\r\n .append(turn.content(), 0, Math.min(500, turn.content().length()))\r\n .append(\"\\n\");\r\n }\r\n sb.append(\"</conversation-context>\");\r\n return sb.toString();\r\n}\r\n```\r\n\r\n2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.\r\n\r\n```java\r\npublic String compact() {\r\n // Save transcript to disk (full history is not lost)\r\n Files.createDirectories(transcriptDir);\r\n Path transcriptPath = transcriptDir.resolve(\r\n \"transcript_\" + System.currentTimeMillis() + \".jsonl\");\r\n try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {\r\n for (ConversationTurn turn : history) {\r\n writer.write(objectMapper.writeValueAsString(turn));\r\n writer.newLine();\r\n }\r\n }\r\n\r\n // LLM generates summary\r\n String conversationText = history.stream()\r\n .map(t -> t.role() + \": \" + t.content())\r\n .reduce(\"\", (a, b) -> a + \"\\n\" + b);\r\n if (conversationText.length() > 80000) {\r\n conversationText = conversationText.substring(0, 80000);\r\n }\r\n\r\n ChatClient summaryClient = ChatClient.builder(chatModel).build();\r\n String summary = summaryClient.prompt()\r\n .user(\"Summarize this conversation for continuity. Include: \"\r\n + \"1) What was accomplished, 2) Current state, \"\r\n + \"3) Key decisions.\\n\\n\" + conversationText)\r\n .call().content();\r\n\r\n // Replace history with summary\r\n history.clear();\r\n history.add(new ConversationTurn(\"system\",\r\n \"[Conversation compressed. Transcript: \" + transcriptPath\r\n + \"]\\n\\n\" + summary));\r\n return summary;\r\n}\r\n```\r\n\r\n3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.\r\n\r\n```java\r\npublic class CompactTool {\r\n private final ContextCompactor compactor;\r\n\r\n public CompactTool(ContextCompactor compactor) {\r\n this.compactor = compactor;\r\n }\r\n\r\n @Tool(description = \"Trigger manual conversation compression to free up context space.\")\r\n public String compact(\r\n @ToolParam(description = \"What to preserve in summary\",\r\n required = falLine truncated
},
{
"version": "s07",
"locale": "en",
"title": "s07: Task System",
"content": "# s07: Task System\n\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\n\n> *\"Break big goals into small tasks, order them, persist to disk\"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.\n\n## Problem\n\ns03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.\n\nWithout explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compression (s06) wipes it clean.\n\n## Solution\n\nPromote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`), and dependents (`blocks`). The graph answers three questions at any moment:\n\n- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.\n- **What's blocked?** -- tasks waiting on unfinished dependencies.\n- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\"}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\n\nTask graph (DAG):\n +----------+\n +--> | task 2 | --+\n | | pending | |\n+----------+ +----------+ +--> +----------+\n| task 1 | | task 4 |\n| completed| --> +----------+ +--> | blocked |\n+----------+ | task 3 | --+ +----------+\n | pending |\n +----------+\n\nOrdering: task 1 must finish before 2 and 3\nParallelism: tasks 2 and 3 can run at the same time\nDependencies: task 4 waits for both 2 and 3\nStatus: pending -> in_progress -> completed\n```\n\nThis task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.\n\n## How It Works\n\n1. **TaskManager**: one JSON file per task, CRUD with dependency graph.\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. **Status + dependency wiring**: `update` handles transitions and dependency edges.\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. Four task tools go into the dispatch map.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\nFrom s07 onward, the task graph is the default for multi-step work. s03's Todo remains for quick single-session checklists.\n\n## What Changed From s06\n\n| Component | Before (s06) | After (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| Planning model | Flat checklist (in-memory) | Task graph with dependencies (on disk) |\n| Relationships | None | `blockedBy` + `blocks` edges |\n| Status tracking | Done or not | `pending` -> `in_progress` -> `completed` |\n| Persistence | Lost on compression | Survives compression and restarts |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s07_task_system.py\n```\n\n1. `Create 3 tasks: \"Setup project\", \"Write code\", \"Write tests\". Make them depend on each other in order.`\n2. `List all tasks and show the dependency graph`\n3. `Complete task 1 and then list tasks to see task 2 unblocked`\n4. `Create a task boaLine truncated
"content": "# s07: Task System\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Break big goals into small tasks, order them, persist to disk\"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.\r\n>\r\n> **Harness layer**: Persistent tasks -- goals that outlive any single conversation.\r\n\r\n## Problem\r\n\r\ns03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.\r\n\r\nWithout explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compaction (s06) wipes it clean.\r\n\r\n## Solution\r\n\r\nPromote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`), and dependents (`blocks`). The graph answers three questions at any moment:\r\n\r\n- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.\r\n- **What's blocked?** -- tasks waiting on unfinished dependencies.\r\n- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.\r\n\r\n```\r\n.tasks/\r\n task_1.json {\"id\":1, \"status\":\"completed\"}\r\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\r\n\r\nTask graph (DAG):\r\n +----------+\r\n +--> | task 2 | --+\r\n | | pending | |\r\n+----------+ +----------+ +--> +----------+\r\n| task 1 | | task 4 |\r\n| completed| --> +----------+ +--> | blocked |\r\n+----------+ | task 3 | --+ +----------+\r\n | pending |\r\n +----------+\r\n\r\nOrdering: task 1 must finish before 2 and 3\r\nParallelism: tasks 2 and 3 can run at the same time\r\nDependencies: task 4 waits for both 2 and 3\r\nStatus: pending -> in_progress -> completed\r\n```\r\n\r\nThis task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.\r\n\r\n## How It Works\r\n\r\n1. **TaskManager**: one JSON file per task, CRUD with dependency graph. Uses Jackson `ObjectMapper` for JSON serialization.\r\n\r\n```java\r\npublic class TaskManager {\r\n private static final ObjectMapper MAPPER = new ObjectMapper();\r\n private final Path dir;\r\n private int nextId;\r\n\r\n public TaskManager(Path tasksDir) {\r\n this.dir = tasksDir;\r\n Files.createDirectories(dir);\r\n this.nextId = maxId() + 1;\r\n }\r\n\r\n @Tool(description = \"Create a new task with subject and optional description\")\r\n public String taskCreate(\r\n @ToolParam(description = \"Short subject of the task\") String subject,\r\n @ToolParam(description = \"Detailed description\", required = false) String description) {\r\n Map<String, Object> task = new LinkedHashMap<>();\r\n task.put(\"id\", nextId);\r\n task.put(\"subject\", subject);\r\n task.put(\"status\", \"pending\");\r\n task.put(\"blockedBy\", new ArrayList<>());\r\n task.put(\"blocks\", new ArrayList<>());\r\n save(task);\r\n nextId++;\r\n return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n }\r\n}\r\n```\r\n\r\n2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.\r\n\r\n```java\r\nprivate void clearDependency(int completedId) {\r\n try (Stream<Path> files = Files.list(dir)) {\r\n files.filter(f -> f.getFileName().toString().matches(\"task_\\\\d+\\\\.json\"))\r\n .forEach(f -> {\r\n Map<String, Object> task = MAPPER.readValue(\r\n Files.readString(f), new TypeReference<>() {});\r\n List<Integer> blockedBy = (List<Integer>) task.get(\"blockedBy\");\r\n if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {\r\n save(task);\r\n }\r\n });\r\n }\r\n}\r\n```\r\n\r\n3. **Status transitions + dependency wiring**: `taskUpdate` handles status transitions and dependency edges. When status changes to `completed`, it automatically calls `clearDependency`; `blockedBy`/`blocks` are bidirectional relationships.\r\n\r\n```java\r\n@Tool(description = \"Update a task's status or dependencies.\")\r\npublic String taskUpdate(\r\n @ToolParam(description = \"Task ID\") int taskId,\r\n @ToolParam(desLine truncated
},
{
"version": "s08",
"locale": "en",
"title": "s08: Background Tasks",
"content": "# s08: Background Tasks\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\n\n> *\"Run slow operations in the background; the agent keeps thinking\"* -- daemon threads run commands, inject notifications on completion.\n\n## Problem\n\nSome commands take minutes: `npm install`, `pytest`, `docker build`. With a blocking loop, the model sits idle waiting. If the user asks \"install dependencies and while that runs, create the config file,\" the agent does them sequentially, not in parallel.\n\n## Solution\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## How It Works\n\n1. BackgroundManager tracks tasks with a thread-safe notification queue.\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` starts a daemon thread and returns immediately.\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. When the subprocess finishes, its result goes into the notification queue.\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. The agent loop drains notifications before each LLM call.\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\nThe loop stays single-threaded. Only subprocess I/O is parallelized.\n\n## What Changed From s07\n\n| Component | Before (s07) | After (s08) |\n|----------------|------------------|----------------------------|\n| Tools | 8 | 6 (base + background_run + check)|\n| Execution | Blocking only | Blocking + background threads|\n| Notification | None | Queue drained per loop |\n| Concurrency | None | Daemon threads |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
"content": "# s08: Background Tasks\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\r\n\r\n> *\"Run slow operations in the background; the agent keeps thinking\"* -- background threads run commands, inject notifications on completion.\r\n>\r\n> **Harness layer**: Background execution -- the model thinks while the harness waits.\r\n\r\n## Problem\r\n\r\nSome commands take minutes: `npm install`, `pytest`, `docker build`. With a blocking loop, the model sits idle waiting. If the user asks \"install dependencies and while that runs, create the config file,\" the agent does them sequentially, not in parallel.\r\n\r\n## Solution\r\n\r\n```\r\nMain thread Background thread\r\n+-----------------+ +-----------------+\r\n| agent loop | | subprocess runs |\r\n| ... | | ... |\r\n| [LLM call] <---+------- | enqueue(result) |\r\n| ^drain queue | +-----------------+\r\n+-----------------+\r\n\r\nTimeline:\r\nAgent --[spawn A]--[spawn B]--[other work]----\r\n | |\r\n v v\r\n [A runs] [B runs] (parallel)\r\n | |\r\n +-- results injected before next LLM call --+\r\n```\r\n\r\n## How It Works\r\n\r\n1. BackgroundManager tracks tasks with thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.\r\n\r\n```java\r\npublic class BackgroundManager {\r\n private static final int TIMEOUT_SECONDS = 300;\r\n\r\n private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();\r\n private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();\r\n private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\r\n\r\n record TaskInfo(String status, String result, String command) {}\r\n public record Notification(String taskId, String status, String command, String result) {}\r\n}\r\n```\r\n\r\n2. `backgroundRun()` submits a virtual thread (Java 21) and returns immediately. Compared to Python's `daemon=True` threads, virtual threads are lighter and scheduled by the JVM.\r\n\r\n```java\r\n@Tool(description = \"Run a command in a background thread. Returns task_id immediately without waiting.\")\r\npublic String backgroundRun(\r\n @ToolParam(description = \"The shell command to run in background\") String command) {\r\n String taskId = UUID.randomUUID().toString().substring(0, 8);\r\n tasks.put(taskId, new TaskInfo(\"running\", null, command));\r\n\r\n executor.submit(() -> execute(taskId, command));\r\n\r\n return \"Background task \" + taskId + \" started: \"\r\n + command.substring(0, Math.min(80, command.length()));\r\n}\r\n```\r\n\r\n3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.\r\n\r\n```java\r\nprivate void execute(String taskId, String command) {\r\n String status, output;\r\n try {\r\n ProcessBuilder pb = new ProcessBuilder(\"sh\", \"-c\", command);\r\n pb.redirectErrorStream(true);\r\n Process process = pb.start();\r\n try (BufferedReader reader = new BufferedReader(\r\n new InputStreamReader(process.getInputStream()))) {\r\n output = reader.lines().collect(Collectors.joining(\"\\n\"));\r\n }\r\n boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);\r\n if (!finished) { process.destroyForcibly(); status = \"timeout\"; }\r\n else { status = \"completed\"; }\r\n } catch (Exception e) { output = \"Error: \" + e.getMessage(); status = \"error\"; }\r\n\r\n tasks.put(taskId, new TaskInfo(status, output, command));\r\n notificationQueue.add(new Notification(taskId, status, command, output));\r\n}\r\n```\r\n\r\n4. Drain the notification queue on each user input and inject into the system prompt. Spring AI's `ChatClient` manages the internal tool loop, so notifications are drained and built into the system prompt on each user input instead -- the core concept remains the same: fire and forget.\r\n\r\n```java\r\nAgentRunner.interactive(\"s08\", userMessage -> {\r\n // Drain background task notifications (corresponds to Python's pre-loop drain_notifications)\r\n var notifs = bgManager.drainNotifications();\r\n String bgContext = \"\";\r\n if (!notifs.isEmpty()) {\r\n String notifText = notifs.stream()\r\n .map(n -> \"[bg:\" + n.taskId() + \"] \" + n.status() + \": \" + n.result())\r\n .collect(Collectors.joining(\"\\n\"));\r\n bgContext = \"\\n\\n<background-results>\\n\" + notifText + \"\\n</background-results>\";\r\n }\r\n\r\n String system = \"You are a coding agent. Use backgroundRun for long-running commands.\"\r\n + bgContext;\r\n\r\n ChatClient chatClient = ChatClient.builder(chatModel)\Line truncated
},
{
"version": "s09",
"locale": "en",
"title": "s09: Agent Teams",
"content": "# s09: Agent Teams\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\n\n> *\"When the task is too big for one, delegate to teammates\"* -- persistent teammates + async mailboxes.\n\n## Problem\n\nSubagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.\n\nReal teamwork needs: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.\n\n## Solution\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## How It Works\n\n1. TeammateManager maintains config.json with the team roster.\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` creates a teammate and starts its agent loop in a thread.\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. Each teammate checks its inbox before every LLM call, injecting received messages into context.\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## What Changed From s08\n\n| Component | Before (s08) | After (s09) |\n|----------------|------------------|----------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| Agents | Single | Lead + N teammates |\n| Persistence | None | config.json + JSONL inboxes|\n| Threads | Background cmds | Full agent loops per thread|\n| Lifecycle | Fire-and-forget | idle -> working -> idle |\n| Communication | None | message + broadcast |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. Type `/team` to see the team roster with statuses\n5. Type `/inbox` to manually check the lead's inbox\n"
"content": "# s09: Agent Teams\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\r\n\r\n> *\"When the task is too big for one, delegate to teammates\"* -- persistent teammates + JSONL mailboxes.\r\n>\r\n> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.\r\n\r\n## Problem\r\n\r\nSubagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.\r\n\r\nReal teamwork needs three things: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.\r\n\r\n## Solution\r\n\r\n```\r\nTeammate lifecycle:\r\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\r\n\r\nCommunication:\r\n .team/\r\n config.json <- team roster + statuses\r\n inbox/\r\n alice.jsonl <- append-only, drain-on-read\r\n bob.jsonl\r\n lead.jsonl\r\n\r\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\r\n | alice | -----------------------------> | bob |\r\n | loop | bob.jsonl << {json_line} | loop |\r\n +--------+ +--------+\r\n ^ |\r\n | BUS.read_inbox(\"alice\") |\r\n +---- alice.jsonl -> read + drain ---------+\r\n```\r\n\r\n## How It Works\r\n\r\n1. TeammateManager maintains the team roster via config.json.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s09/TeammateManager.java\r\npublic class TeammateManager {\r\n private final ChatModel chatModel;\r\n private final MessageBus bus;\r\n private final Path configPath;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n private Map<String, Object> config;\r\n // Python uses threading.Thread + dict; Java uses ConcurrentHashMap for natural thread safety\r\n private final Map<String, Thread> threads = new ConcurrentHashMap<>();\r\n\r\n public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {\r\n this.chatModel = chatModel;\r\n this.bus = bus;\r\n this.configPath = teamDir.resolve(\"config.json\");\r\n Files.createDirectories(teamDir);\r\n this.config = loadConfig();\r\n }\r\n```\r\n\r\n2. `spawn()` creates a teammate and starts its agent loop in a thread.\r\n\r\n```java\r\n// Python uses threading.Thread; Java uses Thread.startVirtualThread() for virtual threads\r\npublic synchronized String spawn(String name, String role, String prompt) {\r\n Map<String, Object> member = new LinkedHashMap<>();\r\n member.put(\"name\", name);\r\n member.put(\"role\", role);\r\n member.put(\"status\", \"working\");\r\n ((List<Map<String, Object>>) config.get(\"members\")).add(member);\r\n saveConfig();\r\n\r\n // Virtual thread: lightweight, JVM-scheduled, doesn't occupy OS threads\r\n Thread thread = Thread.startVirtualThread(\r\n () -> teammateLoop(name, role, prompt));\r\n threads.put(name, thread);\r\n return \"Spawned '\" + name + \"' (role: \" + role + \")\";\r\n}\r\n```\r\n\r\n3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/core/team/MessageBus.java\r\n// Python relies on GIL for implicit thread safety; Java uses synchronized for explicit safety\r\npublic class MessageBus {\r\n private final Path inboxDir;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n\r\n public synchronized String send(String sender, String to, String content,\r\n String msgType, Map<String, Object> extra) {\r\n Map<String, Object> msg = new LinkedHashMap<>();\r\n msg.put(\"type\", msgType);\r\n msg.put(\"from\", sender);\r\n msg.put(\"content\", content);\r\n msg.put(\"timestamp\", System.currentTimeMillis() / 1000.0);\r\n if (extra != null) msg.putAll(extra);\r\n\r\n Path inbox = inboxDir.resolve(to + \".jsonl\");\r\n Files.writeString(inbox, mapper.writeValueAsString(msg) + \"\\n\",\r\n StandardOpenOption.CREATE, StandardOpenOption.APPEND);\r\n return \"Sent \" + msgType + \" to \" + to;\r\n }\r\n\r\n public synchronized List<Map<String, Object>> readInbox(String name) {\r\n Path inbox = inboxDir.resolve(name + \".jsonl\");\r\n if (!Files.exists(inbox)) return List.of();\r\n List<Map<String, Object>> messages = new ArrayList<>();\r\n for (String line : Files.readAllLines(inbox)) {\r\n if (!line.isBlank())\r\n messages.add(mapper.readValue(line, new TypeReference<>() {}));\r\n }\r\n Files.writeString(inbox, \"\"); // drain\r\n return messages;\r\n }\r\nLine truncated
},
{
"version": "s10",
"locale": "en",
"title": "s10: Team Protocols",
"content": "# s10: Team Protocols\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\n\n> *\"Teammates need shared communication rules\"* -- one request-response pattern drives all negotiation.\n\n## Problem\n\nIn s09, teammates work and communicate but lack structured coordination:\n\n**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake: the lead requests, the teammate approves (finish and exit) or rejects (keep working).\n\n**Plan approval**: When the lead says \"refactor the auth module,\" the teammate starts immediately. For high-risk changes, the lead should review the plan first.\n\nBoth share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.\n\n## Solution\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## How It Works\n\n1. The lead initiates shutdown by generating a request_id and sending through the inbox.\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. The teammate receives the request and responds with approve/reject.\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\nOne FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol.\n\n## What Changed From s09\n\n| Component | Before (s09) | After (s10) |\n|----------------|------------------|------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan)|\n| Shutdown | Natural exit only| Request-response handshake |\n| Plan gating | None | Submit/review with approval |\n| Correlation | None | request_id per request |\n| FSM | None | pending -> approved/rejected |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. Type `/team` to monitor statuses\n"
"content": "# s10: Team Protocols\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\r\n\r\n> *\"Teammates need shared communication rules\"* -- one request-response pattern drives all negotiation.\r\n>\r\n> **Harness layer**: Protocols -- structured handshakes between models.\r\n\r\n## Problem\r\n\r\nIn s09, teammates work and communicate but lack structured coordination:\r\n\r\n**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake -- the lead requests, the teammate approves (finish and exit) or rejects (keep working).\r\n\r\n**Plan approval**: When the lead says \"refactor the auth module,\" the teammate starts immediately. For high-risk changes, the lead should review the plan first.\r\n\r\nBoth share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.\r\n\r\n## Solution\r\n\r\n```\r\nShutdown Protocol Plan Approval Protocol\r\n================== ======================\r\n\r\nLead Teammate Teammate Lead\r\n | | | |\r\n |--shutdown_req-->| |--plan_req------>|\r\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\r\n | | | |\r\n |<--shutdown_resp-| |<--plan_resp-----|\r\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\r\n | approve:true} | | approve:true} |\r\n\r\nShared FSM:\r\n [pending] --approve--> [approved]\r\n [pending] --reject---> [rejected]\r\n\r\nTrackers:\r\n shutdown_requests = {req_id: {target, status}}\r\n plan_requests = {req_id: {from, plan, status}}\r\n```\r\n\r\n## How It Works\r\n\r\n1. The lead initiates shutdown by generating a request_id and sending through the inbox.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java\r\n// Python uses dict + threading.Lock; Java uses ConcurrentHashMap for natural thread safety\r\nprivate final ConcurrentHashMap<String, Map<String, String>> shutdownRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String handleShutdownRequest(String teammate) {\r\n String reqId = UUID.randomUUID().toString().substring(0, 8);\r\n shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(\r\n \"target\", teammate, \"status\", \"pending\")));\r\n bus.send(\"lead\", teammate, \"Please shut down gracefully.\",\r\n \"shutdown_request\", Map.of(\"request_id\", reqId));\r\n return \"Shutdown request \" + reqId + \" sent to '\" + teammate\r\n + \"' (status: pending)\";\r\n}\r\n```\r\n\r\n2. The teammate receives the request and responds with approve/reject.\r\n\r\n```java\r\n// TeammateProtocolTool - teammates respond to shutdown requests via @Tool annotation\r\n@Tool(description = \"Respond to a shutdown request\")\r\npublic String shutdownResponse(\r\n @ToolParam(description = \"The request_id\") String requestId,\r\n @ToolParam(description = \"true to approve\") boolean approve,\r\n @ToolParam(description = \"Reason for decision\") String reason) {\r\n return tracker.respondToShutdown(name, requestId, approve, reason);\r\n}\r\n\r\n// ProtocolTracker - updates tracker + sends response message\r\npublic String respondToShutdown(String sender, String requestId,\r\n boolean approve, String reason) {\r\n var req = shutdownRequests.get(requestId);\r\n if (req != null) {\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n }\r\n bus.send(sender, \"lead\", reason != null ? reason : \"\",\r\n \"shutdown_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve));\r\n return \"Shutdown \" + (approve ? \"approved\" : \"rejected\");\r\n}\r\n```\r\n\r\n3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).\r\n\r\n```java\r\n// ProtocolTracker - same request_id correlation pattern, two use cases\r\nprivate final ConcurrentHashMap<String, Map<String, String>> planRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String reviewPlan(String requestId, boolean approve, String feedback) {\r\n var req = planRequests.get(requestId);\r\n if (req == null) return \"Error: Unknown plan request_id '\" + requestId + \"'\";\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n bus.send(\"lead\", req.get(\"from\"), feedback != null ? feedback : \"\",\r\n \"plan_approval_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve,\r\n \"feedback\", feedback != null ? feedback : \"\"));\r\n return \"Plan \" + req.get(\"status\") + \" for '\" + req.get(\"from\") + \"'\";\r\n}\r\n```\r\n\r\nOne FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-Line truncated
},
{
"version": "s11",
"locale": "en",
"title": "s11: Autonomous Agents",
"content": "# s11: Autonomous Agents\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\n\n> *\"Teammates scan the board and claim tasks themselves\"* -- no need for the lead to assign each one.\n\n## Problem\n\nIn s09-s10, teammates only work when explicitly told to. The lead must spawn each one with a specific prompt. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.\n\nTrue autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.\n\nOne subtlety: after context compression (s06), the agent might forget who it is. Identity re-injection fixes this.\n\n## Solution\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## How It Works\n\n1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. The idle phase polls inbox and task board in a loop.\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. Task board scanning: find pending, unowned, unblocked tasks.\n\n```python\ndef scan_unclaimed_tasks() -> list:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. Identity re-injection: when context is too short (compression happened), insert an identity block.\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## What Changed From s10\n\n| Component | Before (s10) | After (s11) |\n|----------------|------------------|----------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| Autonomy | Lead-directed | Self-organizing |\n| Idle phase | None | Poll inbox + task board |\n| Task claiming | Manual only | Auto-claim unclaimed tasks |\n| Identity | System prompt | + re-injection after compress|\n| Timeout | None | 60s idle -> auto shutdown |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s11_autonomous_agents.py\n```\n\n1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`\n2. `Spawn a coder teammate and let it find work from the task board itself`\n3. `Create tasks with dependencies. Watch teammates respect the blocked order.`\n4. Type `/tasks` to see the task board with owners\n5. Type `/team` to monitor who is working vs idle\n"
"content": "# s11: Autonomous Agents\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\r\n\r\n> *\"Teammates scan the board and claim tasks themselves\"* -- no need for the lead to assign each one. Self-organizing.\r\n>\r\n> **Harness layer**: Autonomy -- models that find work without being told.\r\n\r\n## Problem\r\n\r\nIn s09-s10, teammates only work when explicitly told to. The lead must write a prompt for each teammate. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.\r\n\r\nTrue autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.\r\n\r\nOne subtlety: after context compaction (s06), the agent might forget who it is. Identity re-injection fixes this.\r\n\r\n## Solution\r\n\r\n```\r\nTeammate lifecycle with idle cycle:\r\n\r\n+-------+\r\n| spawn |\r\n+---+---+\r\n |\r\n v\r\n+-------+ tool_use +-------+\r\n| WORK | <------------- | LLM |\r\n+---+---+ +-------+\r\n |\r\n | stop_reason != tool_use (or idle tool called)\r\n v\r\n+--------+\r\n| IDLE | poll every 5s for up to 60s\r\n+---+----+\r\n |\r\n +---> check inbox --> message? ----------> WORK\r\n |\r\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\r\n |\r\n +---> 60s timeout ----------------------> SHUTDOWN\r\n\r\nIdentity via system prompt (always present):\r\n ChatClient.builder(chatModel)\r\n .defaultSystem(identityPrompt) // automatically included in every call\r\n```\r\n\r\n## How It Works\r\n\r\n1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java\r\n// AutonomousTeammateManager.autonomousLoop()\r\n\r\nprivate void autonomousLoop(String name, String role, String initialPrompt) {\r\n // idle flag: set by tool call, detected by outer loop\r\n AtomicBoolean idleRequested = new AtomicBoolean(false);\r\n var idleTool = new IdleTool(idleRequested);\r\n\r\n ChatClient client = ChatClient.builder(chatModel)\r\n .defaultSystem(sysPrompt)\r\n .defaultTools(new BashTool(), new ReadFileTool(),\r\n new WriteFileTool(), new EditFileTool(),\r\n messageTool, protocolTool, idleTool, claimTool)\r\n .build();\r\n\r\n while (true) {\r\n // -- WORK PHASE --\r\n String nextMsg = initialPrompt;\r\n for (int round = 0; round < 50 && nextMsg != null; round++) {\r\n var inbox = bus.readInbox(name);\r\n // ... merge inbox messages into nextMsg ...\r\n idleRequested.set(false);\r\n String response = client.prompt(sb.toString()).call().content();\r\n if (idleRequested.get()) break; // idle tool was called\r\n nextMsg = null; // subsequent rounds are inbox-driven\r\n }\r\n\r\n // -- IDLE PHASE --\r\n setStatus(name, \"idle\");\r\n // ... poll inbox + task board (see below) ...\r\n if (!resume) { setStatus(name, \"shutdown\"); return; }\r\n setStatus(name, \"working\");\r\n }\r\n}\r\n```\r\n\r\n2. The idle phase polls inbox and task board in a loop.\r\n\r\n```java\r\n// IDLE PHASE: poll inbox + task board\r\nsetStatus(name, \"idle\");\r\nboolean resume = false;\r\nint polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12\r\n\r\nfor (int p = 0; p < polls; p++) {\r\n Thread.sleep(POLL_INTERVAL * 1000L);\r\n\r\n // Check inbox\r\n var inbox = bus.readInbox(name);\r\n if (!inbox.isEmpty()) {\r\n initialPrompt = \"<inbox>\" + mapper.writeValueAsString(inbox) + \"</inbox>\";\r\n resume = true;\r\n break;\r\n }\r\n\r\n // Scan task board\r\n var unclaimed = scanUnclaimedTasks(tasksDir);\r\n if (!unclaimed.isEmpty()) {\r\n var task = unclaimed.get(0);\r\n int taskId = ((Number) task.get(\"id\")).intValue();\r\n claimTask(tasksDir, taskId, name);\r\n initialPrompt = String.format(\r\n \"<auto-claimed>Task #%d: %s\\n%s</auto-claimed>\",\r\n taskId, task.get(\"subject\"),\r\n task.getOrDefault(\"description\", \"\"));\r\n resume = true;\r\n break;\r\n }\r\n}\r\n\r\nif (!resume) { setStatus(name, \"shutdown\"); return; }\r\nsetStatus(name, \"working\");\r\n```\r\n\r\n3. Task board scanning: find pending, unowned, unblocked tasks.\r\n\r\n```java\r\nstatic List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {\r\n if (!Files.exists(tasksDir)) return List.of();\r\n List<Map<String, Object>> unclaimed = new ArrayList<>();\r\n ObjectMapper mapper = new ObjectMapper();\r\n try (var files = Files.list(tasksDir)) {\r\n files.filter(f -> f.getFileName().toString().startsWith(\"task_\")\r\n && f.getFileName().toString().endsWith(\".jLine truncated
},
{
"version": "s12",
"locale": "en",
"title": "s12: Worktree + Task Isolation",
"content": "# s12: Worktree + Task Isolation\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\n\n> *\"Each works in its own directory, no interference\"* -- tasks manage goals, worktrees manage directories, bound by ID.\n\n## Problem\n\nBy s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide: agent A edits `config.py`, agent B edits `config.py`, unstaged changes mix, and neither can roll back cleanly.\n\nThe task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.\n\n## Solution\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## How It Works\n\n1. **Create a task.** Persist the goal first.\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\nThe binding writes state to both sides:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **Run commands in the worktree.** `cwd` points to the isolated directory.\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **Close out.** Two choices:\n - `worktree_keep(name)` -- preserve the directory for later.\n - `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\nEvents emitted: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`.\n\nAfter a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on disk. Conversation memory is volatile; file state is durable.\n\n## What Changed From s11\n\n| Component | Before (s11) | After (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| Coordination | Task board (owner/status) | Task board + explicit worktree binding |\n| Execution scope | Shared directory | Task-scoped isolated directory |\n| Recoverability | Task status only | Task status + worktree index |\n| Teardown | Task completion | Task completion + explicit keep/remove |\n| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s12_worktree_task_isolation.py\n```\n\n1. `Create tasks for backend auth and frontend login page, then list tasks.`\n2. `Create worktree \"auth-refactor\" for task 1, then bind task 2 to a new worktree \"ui-login\".`\n3. `Run \"git status --short\" in worktree \"auth-refactor\".`\n4. `Keep worktree \"ui-login\", theLine truncated
"content": "# s12: Worktree + Task Isolation\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\r\n\r\n> *\"Each works in its own directory, no interference\"* -- tasks manage goals, worktrees manage directories, bound by ID.\r\n>\r\n> **Harness layer**: Directory isolation -- parallel execution lanes that never collide.\r\n\r\n## Problem\r\n\r\nBy s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide -- agent A edits `Config.java`, agent B also edits `Config.java`, unstaged changes mix, and neither can roll back cleanly.\r\n\r\nThe task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.\r\n\r\n## Solution\r\n\r\n```\r\nControl plane (.tasks/) Execution plane (.worktrees/)\r\n+------------------+ +------------------------+\r\n| task_1.json | | auth-refactor/ |\r\n| status: in_progress <------> branch: wt/auth-refactor\r\n| worktree: \"auth-refactor\" | task_id: 1 |\r\n+------------------+ +------------------------+\r\n| task_2.json | | ui-login/ |\r\n| status: pending <------> branch: wt/ui-login\r\n| worktree: \"ui-login\" | task_id: 2 |\r\n+------------------+ +------------------------+\r\n |\r\n index.json (worktree registry)\r\n events.jsonl (lifecycle log)\r\n\r\nState machines:\r\n Task: pending -> in_progress -> completed\r\n Worktree: absent -> active -> removed | kept\r\n```\r\n\r\n## How It Works\r\n\r\n1. **Create a task.** Persist the goal first.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\ntasks.create(\"Implement auth refactor\", \"\");\r\n// -> .tasks/task_1.json status=pending worktree=\"\"\r\n```\r\n\r\n2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\nworktrees.create(\"auth-refactor\", 1, \"HEAD\");\r\n// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\r\n// -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\r\n```\r\n\r\nThe binding writes state to both sides:\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\npublic String bindWorktree(int taskId, String worktree, String owner) {\r\n var task = load(taskId);\r\n task.put(\"worktree\", worktree);\r\n if (owner != null && !owner.isEmpty()) task.put(\"owner\", owner);\r\n if (\"pending\".equals(task.get(\"status\"))) task.put(\"status\", \"in_progress\");\r\n task.put(\"updated_at\", System.currentTimeMillis() / 1000.0);\r\n save(task);\r\n return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n}\r\n```\r\n\r\n3. **Run commands in the worktree.** `cwd` points to the isolated directory.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()\r\nboolean isWindows = System.getProperty(\"os.name\").toLowerCase().contains(\"win\");\r\nProcessBuilder pb = isWindows\r\n ? new ProcessBuilder(\"cmd\", \"/c\", command)\r\n : new ProcessBuilder(\"sh\", \"-c\", command);\r\npb.directory(path.toFile());\r\npb.redirectErrorStream(true);\r\nProcess p = pb.start();\r\nString out = new String(p.getInputStream().readAllBytes()).trim();\r\nboolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);\r\n```\r\n\r\n4. **Close out.** Two choices:\r\n - `worktree_keep(name)` -- preserve the directory for later.\r\n - `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\npublic String remove(String name, boolean force, boolean completeTask) {\r\n var wt = findWorktree(name);\r\n events.emit(\"worktree.remove.before\", ...);\r\n runGit(\"worktree\", \"remove\", wt.get(\"path\").toString());\r\n if (completeTask && wt.get(\"task_id\") != null) {\r\n int taskId = ((Number) wt.get(\"task_id\")).intValue();\r\n tasks.update(taskId, \"completed\", null);\r\n tasks.unbindWorktree(taskId);\r\n events.emit(\"task.completed\",\r\n Map.of(\"id\", taskId, \"status\", \"completed\"),\r\n Map.of(\"name\", name), null);\r\n }\r\n // Update index.json: status -> \"removed\"\r\n}\r\n```\r\n\r\n5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:\r\n\r\n```json\r\n{\r\n \"event\": \"worktree.remove.aftLine truncated
},
{
"version": "s01",
"locale": "zh",
"title": "s01: The Agent Loop (智能体循环)",
"content": "# s01: The Agent Loop (智能体循环)\n\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"One loop & Bash is all you need\"* -- 一个工具 + 一个循环 = 一个智能体。\n\n## 问题\n\n语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。\n\n## 解决方案\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\n一个退出条件控制整个流程。循环持续运行, 直到模型不再调用工具。\n\n## 工作原理\n\n1. 用户 prompt 作为第一条消息。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. 将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n不到 30 行, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。\n\n## 变更内容\n\n| 组件 | 之前 | 之后 |\n|---------------|------------|--------------------------------|\n| Agent loop | (无) | `while True` + stop_reason |\n| Tools | (无) | `bash` (单一工具) |\n| Messages | (无) | 累积式消息列表 |\n| Control flow | (无) | `stop_reason != \"tool_use\"` |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n4. `Create a directory called test_output and write 3 files in it`\n"
"content": "# s01: The Agent Loop (智能体循环)\r\n\r\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"One loop & Bash is all you need\"* -- 一个工具 + 一个循环 = 一个智能体。\r\n>\r\n> **Harness 层**: 循环 -- 模型与真实世界的第一道连接。\r\n\r\n## 问题\r\n\r\n语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。\r\n\r\n## 解决方案\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tool |\r\n| prompt | | | | execute |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n (ChatClient.call() 自动循环直到无工具调用)\r\n```\r\n\r\n一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。\r\n\r\n## 工作原理\r\n\r\n### 1. 构建 ChatClient:注入模型 + 注册工具\r\n\r\n通过 Spring Boot 自动配置注入 `ChatModel`,用 `ChatClient.builder()` 构建客户端,设置系统提示和工具。\r\n\r\n```java\r\n// TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。\r\n// Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。\r\npublic S01AgentLoop(ChatModel chatModel) {\r\n this.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent at \" + System.getProperty(\"user.dir\")\r\n + \". Use bash to solve tasks. Act, don't explain.\")\r\n .defaultTools(new BashTool()) // @Tool 注解的工具对象\r\n .build();\r\n}\r\n```\r\n\r\n### 2. `@Tool` 注解:声明式工具注册\r\n\r\nSpring AI 通过 `@Tool` 注解自动发现和注册工具。框架在启动时扫描 `defaultTools()` 传入的对象,提取所有 `@Tool` 方法的签名和描述,生成 LLM 需要的 tool schema(名称、参数、描述),然后在每次 `call()` 请求中自动携带。\r\n\r\n```java\r\n// BashTool —— 对应 Python 版的 run_bash() 函数\r\npublic class BashTool {\r\n @Tool(description = \"Run a shell command and return stdout + stderr\")\r\n public String bash(@ToolParam(description = \"The shell command to execute\")\r\n String command) {\r\n // 危险命令检查 + ProcessBuilder 执行 + 超时控制 + 输出截断\r\n // ...\r\n }\r\n}\r\n```\r\n\r\n> 对比 Python 版的手动注册方式:\r\n> - Python: `TOOLS = [{\"name\": \"bash\", \"input_schema\": {...}}]` + `TOOL_HANDLERS = {\"bash\": run_bash}`\r\n> - Java: 只需 `@Tool` + `@ToolParam` 注解,框架自动完成 schema 生成和方法分派\r\n\r\n### 3. Spring AI 内部自动循环:`call()` 的底层实现\r\n\r\n**这是理解 Java 版与 Python 版最关键的区别。** Python 版本需要手写 while 循环来驱动工具调用:\r\n\r\n```python\r\n# Python 版 —— 手动循环\r\ndef agent_loop(messages):\r\n while True:\r\n response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)\r\n # 收集 assistant 消息\r\n messages.append({\"role\": \"assistant\", \"content\": response.content})\r\n if response.stop_reason != \"tool_use\":\r\n return response # 模型不再调用工具,退出循环\r\n # 执行工具并回传结果\r\n for block in response.content:\r\n if block.type == \"tool_use\":\r\n result = TOOL_HANDLERS[block.name](block.input)\r\n messages.append({\"role\": \"user\", \"content\": [{\"type\": \"tool_result\", ...}]})\r\n```\r\n\r\nSpring AI 的 `ChatClient.call()` **内部封装了完全等价的逻辑**:\r\n\r\n```\r\ncall() 内部流程:\r\n ┌─────────────────────────────────────────────────────┐\r\n │ 1. 组装请求: system prompt + user message + tools │\r\n │ 2. 发送给 LLM │\r\n │ 3. 解析响应 │\r\n │ ├── 有 tool_use? ──→ 是: │\r\n │ │ a. 提取工具名和参数 │\r\n │ │ b. 通过反射调用对应的 @Tool 方法 │\r\n │ │ c. 将 tool_result 追加到消息列表 │\r\n │ │ d. 回到步骤 2(自动循环) │\r\n │ └── 否 ──→ 返回最终文本 │\r\n └─────────────────────────────────────────────────────┘\rLine truncated
},
{
"version": "s02",
"locale": "zh",
"title": "s02: Tool Use (工具使用)",
"content": "# s02: Tool Use (工具使用)\n\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"加一个工具, 只加一个 handler\"* -- 循环不用动, 新工具注册进 dispatch map 就行。\n\n## 问题\n\n只有 `bash` 时, 所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩, 每次 bash 调用都是不受约束的安全面。专用工具 (`read_file`, `write_file`) 可以在工具层面做路径沙箱。\n\n关键洞察: 加工具不需要改循环。\n\n## 解决方案\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## 工作原理\n\n1. 每个工具有一个处理函数。路径沙箱防止逃逸工作区。\n\n```python\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. dispatch map 将工具名映射到处理函数。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n加工具 = 加 handler + 加 schema。循环永远不变。\n\n## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|----------------|--------------------|--------------------------------|\n| Tools | 1 (仅 bash) | 4 (bash, read, write, edit) |\n| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |\n| 路径安全 | 无 | `safe_path()` 沙箱 |\n| Agent loop | 不变 | 不变 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
"content": "# s02: Tool Use (工具使用)\r\n\r\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"加一个工具, 只加一个 @Tool 方法\"* -- 循环不用动, 新工具传入 `defaultTools()` 就行。\r\n>\r\n> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。\r\n\r\n## 问题\r\n\r\n只有 `bash` 时, 所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩, 每次 bash 调用都是不受约束的安全面。专用工具 (`read_file`, `write_file`) 可以在工具层面做路径沙箱。\r\n\r\n关键洞察: 加工具不需要改循环。\r\n\r\n## 解决方案\r\n\r\n```\r\n+--------+ +-------+ +--------------------+\r\n| User | ---> | LLM | ---> | defaultTools() |\r\n| prompt | | | | { |\r\n+--------+ +---+---+ | BashTool |\r\n ^ | ReadFileTool |\r\n | | WriteFileTool |\r\n +-----------+ EditFileTool |\r\n tool_result | } |\r\n +--------------------+\r\n\r\nSpring AI 通过 @Tool 注解自动注册和分派。\r\n无需手写 dispatch map,框架扫描工具对象的注解方法即可。\r\n```\r\n\r\n## 工作原理\r\n\r\n1. 每个工具是一个独立的类,用 `@Tool` 注解声明。`PathValidator` 做路径沙箱防止逃逸工作区。\r\n\r\n```java\r\n// PathValidator —— 对应 Python 版的 safe_path() 函数\r\npublic class PathValidator {\r\n private final Path workDir;\r\n\r\n public Path resolve(String relativePath) {\r\n Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();\r\n if (!resolved.startsWith(workDir)) {\r\n throw new IllegalArgumentException(\"Path escapes workspace: \" + relativePath);\r\n }\r\n return resolved;\r\n }\r\n}\r\n\r\n// ReadFileTool —— 对应 Python 版的 run_read() 函数\r\npublic class ReadFileTool {\r\n private final PathValidator pathValidator;\r\n\r\n @Tool(description = \"Read file contents. Optionally limit the number of lines returned.\")\r\n public String readFile(\r\n @ToolParam(description = \"Relative path to the file\") String path,\r\n @ToolParam(description = \"Maximum number of lines to read\", required = false) Integer limit) {\r\n Path filePath = pathValidator.resolve(path);\r\n List<String> lines = Files.readAllLines(filePath);\r\n if (limit != null && limit > 0 && limit < lines.size()) {\r\n lines = lines.subList(0, limit);\r\n }\r\n return String.join(\"\\n\", lines);\r\n }\r\n}\r\n```\r\n\r\n2. 工具注册只需传入 `defaultTools()`。Spring AI 扫描 `@Tool` 注解方法,自动完成名称映射和参数绑定。\r\n\r\n```java\r\n// 对应 Python 版的 TOOL_HANDLERS 字典\r\n// Python: TOOL_HANDLERS = {\"bash\": fn, \"read_file\": fn, \"write_file\": fn, \"edit_file\": fn}\r\n// Java: 只需传入工具对象,@Tool 注解自动注册\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent ...\")\r\n .defaultTools(\r\n new BashTool(), // bash 命令执行\r\n new ReadFileTool(), // 文件读取\r\n new WriteFileTool(), // 文件写入\r\n new EditFileTool() // 文件编辑(查找替换)\r\n )\r\n .build();\r\n```\r\n\r\n3. 调用代码与 s01 完全一致。循环由框架管理,开发者只需关注工具实现。\r\n\r\n```java\r\n// 对比 s01,唯一变化是 defaultTools() 多传了 3 个工具对象\r\n// 循环代码完全相同 —— 这正是 s02 的核心洞察\r\nAgentRunner.interactive(\"s02\", userMessage ->\r\n chatClient.prompt()\r\n .user(userMessage)\r\n .call()\r\n .content()\r\n);\r\n```\r\n\r\n加工具 = 加一个 `@Tool` 类 + 传入 `defaultTools()`。循环永远不变。\r\n\r\n> **TIPS — Python → Java 关键适配点:**\r\n> - Python 的 `TOOL_HANDLERS` 字典 → Spring AI `@Tool` 注解 + `defaultTools()` 自动注册分派\r\n> - Python 的 `safe_path()` 函数 → `PathValidator` 类(相同的路径逃逸检查逻辑)\r\n> - Python 的 `lambda **kw` 参数解包 → `@ToolParam` 注解自动绑定参数\r\n> - Python 的 `block.type == \"tool_use\"` 判断 → Spring AI 内部自动检测和分派\r\n\r\n## 相对 s01 的变更\r\n\r\n| 组件 | 之前 (s01) | 之后 (s02) |\r\n|----------------|-----------------------|----------------------------------------|\r\n| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |\r\n| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |\r\n| 路径安全 | 无 Line truncated
},
{
"version": "s03",
"locale": "zh",
"title": "s03: TodoWrite (待办写入)",
"content": "# s03: TodoWrite (待办写入)\n\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"没有计划的 agent 走哪算哪\"* -- 先列步骤再动手, 完成率翻倍。\n\n## 问题\n\n多步任务中, 模型会丢失进度 -- 重复做过的事、跳步、跑偏。对话越长越严重: 工具结果不断填满上下文, 系统提示的影响力逐渐被稀释。一个 10 步重构可能做完 1-3 步就开始即兴发挥, 因为 4-10 步已经被挤出注意力了。\n\n## 解决方案\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 工作原理\n\n1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`。\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo` 工具和其他工具一样加入 dispatch map。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒。\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n\"同时只能有一个 in_progress\" 强制顺序聚焦。nag reminder 制造问责压力 -- 你不更新计划, 系统就追着你问。\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|----------------|------------------|--------------------------------|\n| Tools | 4 | 5 (+todo) |\n| 规划 | 无 | 带状态的 TodoManager |\n| Nag 注入 | 无 | 3 轮后注入 `<reminder>` |\n| Agent loop | 简单分发 | + rounds_since_todo 计数器 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
"content": "# s03: TodoWrite (待办写入)\r\n\r\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"没有计划的 agent 走哪算哪\"* -- 先列步骤再动手, 完成率翻倍。\r\n>\r\n> **Harness 层**: 规划 -- 让模型不偏航, 但不替它画航线。\r\n\r\n## 问题\r\n\r\n多步任务中, 模型会丢失进度 -- 重复做过的事、跳步、跑偏。对话越长越严重: 工具结果不断填满上下文, 系统提示的影响力逐渐被稀释。一个 10 步重构可能做完 1-3 步就开始即兴发挥, 因为 4-10 步已经被挤出注意力了。\r\n\r\n## 解决方案\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tools |\r\n| prompt | | | | + todo |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n |\r\n +-----------+-----------+\r\n | TodoManager state |\r\n | [ ] task A |\r\n | [>] task B <- doing |\r\n | [x] task C |\r\n +-----------------------+\r\n |\r\n 每次请求时通过 defaultSystem()\r\n 注入最新 todo 状态到系统提示\r\n```\r\n\r\n## 工作原理\r\n\r\n1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`。\r\n\r\n```java\r\npublic class TodoManager {\r\n\r\n public record TodoItem(String id, String text, String status) {}\r\n\r\n private List<TodoItem> items = new ArrayList<>();\r\n\r\n @Tool(description = \"Update the full task list to track progress. \"\r\n + \"Each item must have id, text, status (pending/in_progress/completed). \"\r\n + \"Only one task can be in_progress at a time. Max 20 items.\")\r\n public String updateTodos(\r\n @ToolParam(description = \"The complete list of todo items\")\r\n List<TodoItem> items) {\r\n if (items.size() > 20) return \"Error: Max 20 todos allowed\";\r\n List<TodoItem> validated = new ArrayList<>();\r\n int inProgressCount = 0;\r\n for (TodoItem item : items) {\r\n String status = (item.status() != null)\r\n ? item.status().toLowerCase() : \"pending\";\r\n if (\"in_progress\".equals(status)) inProgressCount++;\r\n validated.add(new TodoItem(item.id(), item.text().trim(), status));\r\n }\r\n if (inProgressCount > 1)\r\n return \"Error: Only one task can be in_progress at a time\";\r\n this.items = validated;\r\n return render();\r\n }\r\n}\r\n```\r\n\r\n2. `TodoManager` 通过 `defaultTools()` 注册, `@Tool` 注解方法自动暴露为工具。\r\n\r\n```java\r\nChatClient chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(system)\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n todoManager // @Tool 注解方法自动注册\r\n )\r\n .build();\r\n```\r\n\r\n3. 系统提示注入: 每次用户输入时, 将最新 todo 状态注入系统提示, 并强调更新指令。\r\n\r\n```java\r\n// 动态系统提示:包含当前 todo 状态\r\nString system = \"You are a coding agent at \" + workDir + \".\\n\"\r\n + \"Use the todo tool to plan multi-step tasks. \"\r\n + \"Mark in_progress before starting, completed when done.\\n\"\r\n + \"IMPORTANT: You MUST call updateTodos regularly.\\n\\n\"\r\n + \"<current-todos>\\n\" + todoManager.render() + \"\\n</current-todos>\";\r\n```\r\n\r\n\"同时只能有一个 in_progress\" 强制顺序聚焦。系统提示中持续注入 todo 状态制造问责压力 -- 模型每次都能看到自己的计划, 不会忘记更新。\r\n\r\n> **TIP**: Python 版在工具循环内追踪 `rounds_since_todo`, 连续 3 轮未调用 todo 时注入 `<reminder>` 文本。Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内注入, 因此改用系统提示注入的方式实现同等效果。\r\n\r\n## 相对 s02 的变更\r\n\r\n| 组件 | 之前 (s02) | 之后 (s03) |\r\n|----------------|------------------|--------------------------------------|\r\n| Tools | 4 | 5 (+TodoManager `@Tool`) |\r\n| 规划 | 无 | 带状态的 TodoManager |\r\n| 状态注入 | 无 | 系统提示注入 `<current-todos>` |\r\n| ChatClient | 固定系统提示 | 每轮重建, 动态注入 todo 状态 |\r\n\r\n## 试一试\r\n\r\n```sh\r\ncd learn-claude-code\r\nmvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWriteLine truncated
},
{
"version": "s04",
"locale": "zh",
"title": "s04: Subagents (子智能体)",
"content": "# s04: Subagents (子智能体)\n\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* -- 子智能体用独立 messages[], 不污染主对话。\n\n## 问题\n\n智能体工作越久, messages 数组越胖。每次读文件、跑命令的输出都永久留在上下文里。\"这个项目用什么测试框架?\" 可能要读 5 个文件, 但父智能体只需要一个词: \"pytest。\"\n\n## 解决方案\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## 工作原理\n\n1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. 子智能体以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父智能体。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n子智能体可能跑了 30+ 次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|----------------|------------------|-------------------------------|\n| Tools | 5 | 5 (基础) + task (仅父端) |\n| 上下文 | 单一共享 | 父 + 子隔离 |\n| Subagent | 无 | `run_subagent()` 函数 |\n| 返回值 | 不适用 | 仅摘要文本 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
"content": "# s04: Subagents (子智能体)\r\n\r\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"大任务拆小, 每个小任务干净的上下文\"* -- 子智能体用独立 messages[], 不污染主对话。\r\n>\r\n> **Harness 层**: 上下文隔离 -- 守护模型的思维清晰度。\r\n\r\n## 问题\r\n\r\n智能体工作越久, messages 数组越胖。每次读文件、跑命令的输出都永久留在上下文里。\"这个项目用什么测试框架?\" 可能要读 5 个文件, 但父智能体只需要一个词: \"pytest。\"\r\n\r\n## 解决方案\r\n\r\n```\r\nParent agent Subagent\r\n+------------------+ +------------------+\r\n| messages=[...] | | messages=[] | <-- fresh\r\n| | dispatch | |\r\n| tool: task | ----------> | while tool_use: |\r\n| prompt=\"...\" | | call tools |\r\n| | summary | append results |\r\n| result = \"...\" | <---------- | return last text |\r\n+------------------+ +------------------+\r\n\r\nParent context stays clean. Subagent context is discarded.\r\n```\r\n\r\n## 工作原理\r\n\r\n1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。\r\n\r\n```java\r\n// 父 Agent:拥有基础工具 + SubagentTool\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent. \"\r\n + \"Use the task tool to delegate subtasks.\")\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n new SubagentTool(chatModel) // 父 Agent 独有\r\n )\r\n .build();\r\n```\r\n\r\n2. 子智能体以全新的 `ChatClient` 启动, 拥有独立上下文。只有最终文本返回给父智能体。\r\n\r\n```java\r\n@Tool(description = \"Spawn a subagent with fresh context. \"\r\n + \"Use for exploration or subtasks that might pollute the main context.\")\r\npublic String task(\r\n @ToolParam(description = \"The task prompt\") String prompt,\r\n @ToolParam(description = \"Short description\", required = false)\r\n String description) {\r\n\r\n // 创建全新的 ChatClient —— 这就是\"上下文隔离\"的全部\r\n ChatClient subClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding subagent. \"\r\n + \"Complete the task, then summarize findings.\")\r\n .defaultTools( // 基础工具, 没有 task (防止递归)\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool()\r\n )\r\n .build();\r\n\r\n String result = subClient.prompt()\r\n .user(prompt)\r\n .call()\r\n .content();\r\n\r\n // 只返回最终文本, 子 Agent 上下文被丢弃\r\n return (result != null) ? result : \"(no summary)\";\r\n}\r\n```\r\n\r\n子智能体可能跑了多次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。Spring AI 的 `ChatClient.call()` 内部管理工具循环, 无需手动限制迭代次数。\r\n\r\n## 相对 s03 的变更\r\n\r\n| 组件 | 之前 (s03) | 之后 (s04) |\r\n|----------------|------------------|---------------------------------------|\r\n| Tools | 5 | 5 (基础) + SubagentTool (仅父端) |\r\n| 上下文 | 单一共享 | 父 + 子隔离 (独立 ChatClient) |\r\n| Subagent | 无 | `SubagentTool.task()` 方法 |\r\n| 返回值 | 不适用 | 仅摘要文本 |\r\n\r\n## 试一试\r\n\r\n```sh\r\ncd learn-claude-code\r\nmvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent\r\n```\r\n\r\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\r\n\r\n1. `Use a subtask to find what testing framework this project uses`\r\n2. `Delegate: read all .java files and summarize what each one does`\r\n3. `Use a task to create a new module, then verify it from here`\r\n"
},
{
"version": "s05",
"locale": "zh",
"title": "s05: Skills (技能加载)",
"content": "# s05: Skills (技能加载)\n\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"用到什么知识, 临时加载什么知识\"* -- 通过 tool_result 注入, 不塞 system prompt。\n\n## 问题\n\n你希望智能体遵循特定领域的工作流: git 约定、测试模式、代码审查清单。全塞进系统提示太浪费 -- 10 个技能, 每个 2000 token, 就是 20,000 token, 大部分跟当前任务毫无关系。\n\n## 解决方案\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n第一层: 系统提示中放技能名称 (低成本)。第二层: tool_result 中按需放完整内容。\n\n## 工作原理\n\n1. 每个技能是一个目录, 包含 `SKILL.md` 文件和 YAML frontmatter。\n\n```\nskills/\n pdf/\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\n code-review/\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\n```\n\n2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.rglob(\"SKILL.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n name = meta.get(\"name\", f.parent.name)\n self.skills[name] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n模型知道有哪些技能 (便宜), 需要时再加载完整内容 (贵)。\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|----------------|------------------|--------------------------------|\n| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |\n| 系统提示 | 静态字符串 | + 技能描述列表 |\n| 知识库 | 无 | skills/\\*/SKILL.md 文件 |\n| 注入方式 | 无 | 两层 (系统提示 + result) |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
"content": "# s05: Skills (技能加载)\r\n\r\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"用到什么知识, 临时加载什么知识\"* -- 通过 tool_result 注入, 不塞 system prompt。\r\n>\r\n> **Harness 层**: 按需知识 -- 模型开口要时才给的领域专长。\r\n\r\n## 问题\r\n\r\n你希望智能体遵循特定领域的工作流: git 约定、测试模式、代码审查清单。全塞进系统提示太浪费 -- 10 个技能, 每个 2000 token, 就是 20,000 token, 大部分跟当前任务毫无关系。\r\n\r\n## 解决方案\r\n\r\n```\r\nSystem prompt (Layer 1 -- always present):\r\n+--------------------------------------+\r\n| You are a coding agent. |\r\n| Skills available: |\r\n| - git: Git workflow helpers | ~100 tokens/skill\r\n| - test: Testing best practices |\r\n+--------------------------------------+\r\n\r\nWhen model calls load_skill(\"git\"):\r\n+--------------------------------------+\r\n| tool_result (Layer 2 -- on demand): |\r\n| <skill name=\"git\"> |\r\n| Full git workflow instructions... | ~2000 tokens\r\n| Step 1: ... |\r\n| </skill> |\r\n+--------------------------------------+\r\n```\r\n\r\n第一层: 系统提示中放技能名称 (低成本)。第二层: tool_result 中按需放完整内容。\r\n\r\n## 工作原理\r\n\r\n1. 每个技能是一个目录, 包含 `SKILL.md` 文件和 YAML frontmatter。\r\n\r\n```\r\nskills/\r\n pdf/\r\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\r\n code-review/\r\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\r\n```\r\n\r\n2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。\r\n\r\n```java\r\npublic class SkillLoader {\r\n\r\n private static final Pattern FRONTMATTER_PATTERN =\r\n Pattern.compile(\"^---\\\\n(.*?)\\\\n---\\\\n(.*)\", Pattern.DOTALL);\r\n\r\n private final Map<String, SkillInfo> skills = new LinkedHashMap<>();\r\n\r\n record SkillInfo(Map<String, String> meta, String body, String path) {}\r\n\r\n public SkillLoader(Path skillsDir) {\r\n loadAll(skillsDir);\r\n }\r\n\r\n /** 递归扫描 skills 目录下所有 SKILL.md 文件 */\r\n private void loadAll(Path skillsDir) {\r\n if (!Files.exists(skillsDir)) return;\r\n try (Stream<Path> paths = Files.walk(skillsDir)) {\r\n paths.filter(p -> p.getFileName().toString().equals(\"SKILL.md\"))\r\n .sorted()\r\n .forEach(p -> {\r\n String text = Files.readString(p);\r\n var parsed = parseFrontmatter(text);\r\n String name = parsed.meta().getOrDefault(\"name\",\r\n p.getParent().getFileName().toString());\r\n skills.put(name, new SkillInfo(\r\n parsed.meta(), parsed.body(), p.toString()));\r\n });\r\n }\r\n }\r\n\r\n /** Layer 1: 获取所有技能的简短描述(用于系统提示注入) */\r\n public String getDescriptions() {\r\n if (skills.isEmpty()) return \"(no skills available)\";\r\n StringBuilder sb = new StringBuilder();\r\n for (var entry : skills.entrySet()) {\r\n String desc = entry.getValue().meta()\r\n .getOrDefault(\"description\", \"No description\");\r\n sb.append(\" - \").append(entry.getKey())\r\n .append(\": \").append(desc).append(\"\\n\");\r\n }\r\n return sb.toString().stripTrailing();\r\n }\r\n\r\n /** Layer 2: 加载指定技能的完整内容(作为 @Tool 方法) */\r\n @Tool(description = \"Load specialized knowledge by name.\")\r\n public String loadSkill(\r\n @ToolParam(description = \"Skill name to load\") String name) {\r\n SkillInfo skill = skills.get(name);\r\n if (skill == null)\r\n return \"Error: Unknown skill '\" + name + \"'. Available: \"\r\n + String.join(\", \", skills.keySet());\r\n return \"<skill name=\\\"\" + name + \"\\\">\\n\"\r\n + skill.body() + \"\\n</skill>\";\r\n }\r\n}\r\n```\r\n\r\n3. 第一层写入系统提示。第二层通过 SkillLoader 上的 `@Tool` 注解方法按需加载。\r\n\r\n```java\r\npublic S05SkillLoading(ChatModel chatModel) {\r\n Path skillsDir = Path.of(System.getProperty(\"user.dir\"), \"skills\");\r\n SkillLoader skillLoader = new SkillLoader(skillsDir);\r\n\r\n // Layer 1: 技能元数据注入系统提示\r\n String system = \"You are a coding agent at \" + System.getProperty(\"user.dir\") + \".\\n\"\r\n + \"Use loadSkill to access specialized knowledge.\\n\\n\"\r\n + \"Skills available:\\n\"\r\n Line truncated
},
{
"version": "s06",
"locale": "zh",
"title": "s06: Context Compact (上下文压缩)",
"content": "# s06: Context Compact (上下文压缩)\n\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"上下文总会满, 要有办法腾地方\"* -- 三层压缩策略, 换来无限会话。\n\n## 问题\n\n上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。\n\n## 解决方案\n\n三层压缩, 激进程度递增:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 工作原理\n\n1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第三层 -- manual compact**: `compact` 工具按需触发同样的摘要机制。\n\n4. 循环整合三层:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\n完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|----------------|------------------|--------------------------------|\n| Tools | 5 | 5 (基础 + compact) |\n| 上下文管理 | 无 | 三层压缩 |\n| Micro-compact | 无 | 旧结果 -> 占位符 |\n| Auto-compact | 无 | token 阈值触发 |\n| Transcripts | 无 | 保存到 .transcripts/ |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
"content": "# s06: Context Compact (上下文压缩)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"上下文总会满, 要有办法腾地方\"* -- 三层压缩策略, 换来无限会话。\r\n>\r\n> **Harness 层**: 压缩 -- 干净的记忆, 无限的会话。\r\n\r\n## 问题\r\n\r\n上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。\r\n\r\n## 解决方案\r\n\r\n三层压缩, 激进程度递增:\r\n\r\n```\r\nEvery turn:\r\n+------------------+\r\n| Tool call result |\r\n+------------------+\r\n |\r\n v\r\n[Layer 1: micro_compact] (silent, every turn)\r\n Replace tool_result > 3 turns old\r\n with \"[Previous: used {tool_name}]\"\r\n |\r\n v\r\n[Check: tokens > 50000?]\r\n | |\r\n no yes\r\n | |\r\n v v\r\ncontinue [Layer 2: auto_compact]\r\n Save transcript to .transcripts/\r\n LLM summarizes conversation.\r\n Replace all messages with [summary].\r\n |\r\n v\r\n [Layer 3: compact tool]\r\n Model calls compact explicitly.\r\n Same summarization as auto_compact.\r\n```\r\n\r\n## 工作原理\r\n\r\n1. **第一层 -- 上下文窗口管理**: Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内插入压缩。Java 版通过限制注入系统提示的对话轮数(仅保留最近 N 轮)并截断内容来实现等价效果。\r\n\r\n```java\r\n/** 估算 token 数量: 粗略估计 4 字符 ≈ 1 token */\r\npublic int estimateTokens() {\r\n int chars = history.stream().mapToInt(t -> t.content().length()).sum();\r\n return chars / 4;\r\n}\r\n\r\n/** 获取对话历史的摘要(用于注入系统提示, 仅保留最近几轮) */\r\npublic String getContextSummary() {\r\n if (history.isEmpty()) return \"\";\r\n StringBuilder sb = new StringBuilder(\"\\n<conversation-context>\\n\");\r\n int start = Math.max(0, history.size() - KEEP_RECENT * 2);\r\n for (int i = start; i < history.size(); i++) {\r\n ConversationTurn turn = history.get(i);\r\n sb.append(\"[\").append(turn.role()).append(\"]: \")\r\n .append(turn.content(), 0, Math.min(500, turn.content().length()))\r\n .append(\"\\n\");\r\n }\r\n sb.append(\"</conversation-context>\");\r\n return sb.toString();\r\n}\r\n```\r\n\r\n2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。\r\n\r\n```java\r\npublic String compact() {\r\n // 保存 transcript 到磁盘(完整历史不丢失)\r\n Files.createDirectories(transcriptDir);\r\n Path transcriptPath = transcriptDir.resolve(\r\n \"transcript_\" + System.currentTimeMillis() + \".jsonl\");\r\n try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {\r\n for (ConversationTurn turn : history) {\r\n writer.write(objectMapper.writeValueAsString(turn));\r\n writer.newLine();\r\n }\r\n }\r\n\r\n // LLM 生成摘要\r\n String conversationText = history.stream()\r\n .map(t -> t.role() + \": \" + t.content())\r\n .reduce(\"\", (a, b) -> a + \"\\n\" + b);\r\n if (conversationText.length() > 80000) {\r\n conversationText = conversationText.substring(0, 80000);\r\n }\r\n\r\n ChatClient summaryClient = ChatClient.builder(chatModel).build();\r\n String summary = summaryClient.prompt()\r\n .user(\"Summarize this conversation for continuity. Include: \"\r\n + \"1) What was accomplished, 2) Current state, \"\r\n + \"3) Key decisions.\\n\\n\" + conversationText)\r\n .call().content();\r\n\r\n // 用摘要替换历史\r\n history.clear();\r\n history.add(new ConversationTurn(\"system\",\r\n \"[Conversation compressed. Transcript: \" + transcriptPath\r\n + \"]\\n\\n\" + summary));\r\n return summary;\r\n}\r\n```\r\n\r\n3. **第三层 -- manual compact**: `CompactTool` 工具按需触发同样的摘要机制。\r\n\r\n```java\r\npublic class CompactTool {\r\n private final ContextCompactor compactor;\r\n\r\n public CompactTool(ContextCompactor compactor) {\r\n this.compactor = compactor;\r\n }\r\n\r\n @Tool(description = \"Trigger manual conversation compression to free up context space.\")\r\n public String compact(\r\n @ToolParam(description = \"What to preserve in summary\",\r\n required = false) String focus) {\r\n compactor.requestCompact();\r\n return \"Compression triggered. Context will be summarized.\";\r\n }\r\n}\r\n```\r\n\r\n4. REPL 层整合三层 (Spring AI 的 ChatClient 自Line truncated
},
{
"version": "s07",
"locale": "zh",
"title": "s07: Task System (任务系统)",
"content": "# s07: Task System (任务系统)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\n\n> *\"大目标要拆成小任务, 排好序, 记在磁盘上\"* -- 文件持久化的任务图, 为多 agent 协作打基础。\n\n## 问题\n\ns03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖、状态只有做完没做完。真实目标是有结构的 -- 任务 B 依赖任务 A, 任务 C 和 D 可以并行, 任务 E 要等 C 和 D 都完成。\n\n没有显式的关系, 智能体分不清什么能做、什么被卡住、什么能同时跑。而且清单只活在内存里, 上下文压缩 (s06) 一跑就没了。\n\n## 解决方案\n\n把扁平清单升级为持久化到磁盘的**任务图**。每个任务是一个 JSON 文件, 有状态、前置依赖 (`blockedBy`) 和后置依赖 (`blocks`)。任务图随时回答三个问题:\n\n- **什么可以做?** -- 状态为 `pending` 且 `blockedBy` 为空的任务。\n- **什么被卡住?** -- 等待前置任务完成的任务。\n- **什么做完了?** -- 状态为 `completed` 的任务, 完成时自动解锁后续任务。\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\"}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\n\n任务图 (DAG):\n +----------+\n +--> | task 2 | --+\n | | pending | |\n+----------+ +----------+ +--> +----------+\n| task 1 | | task 4 |\n| completed| --> +----------+ +--> | blocked |\n+----------+ | task 3 | --+ +----------+\n | pending |\n +----------+\n\n顺序: task 1 必须先完成, 才能开始 2 和 3\n并行: task 2 和 3 可以同时执行\n依赖: task 4 要等 2 和 3 都完成\n状态: pending -> in_progress -> completed\n```\n\n这个任务图是 s07 之后所有机制的协调骨架: 后台执行 (s08)、多 agent 团队 (s09+)、worktree 隔离 (s12) 都读写这同一个结构。\n\n## 工作原理\n\n1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. **状态变更 + 依赖关联**: `update` 处理状态转换和依赖边。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. 四个任务工具加入 dispatch map。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\n从 s07 起, 任务图是多步工作的默认选择。s03 的 Todo 仍可用于单次会话内的快速清单。\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| 规划模型 | 扁平清单 (仅内存) | 带依赖关系的任务图 (磁盘) |\n| 关系 | 无 | `blockedBy` + `blocks` 边 |\n| 状态追踪 | 做完没做完 | `pending` -> `in_progress` -> `completed` |\n| 持久化 | 压缩后丢失 | 压缩和重启后存活 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s07_task_system.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Create 3 tasks: \"Setup project\", \"Write code\", \"Write tests\". Make them depend on each other in order.`\n2. `List all tasks and show the dependency graph`\n3. `Complete task 1 and then list tasks to see task 2 unblocked`\n4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in paralleLine truncated
"content": "# s07: Task System (任务系统)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"大目标要拆成小任务, 排好序, 记在磁盘上\"* -- 文件持久化的任务图, 为多 agent 协作打基础。\r\n>\r\n> **Harness 层**: 持久化任务 -- 比任何一次对话都长命的目标。\r\n\r\n## 问题\r\n\r\ns03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖、状态只有做完没做完。真实目标是有结构的 -- 任务 B 依赖任务 A, 任务 C 和 D 可以并行, 任务 E 要等 C 和 D 都完成。\r\n\r\n没有显式的关系, 智能体分不清什么能做、什么被卡住、什么能同时跑。而且清单只活在内存里, 上下文压缩 (s06) 一跑就没了。\r\n\r\n## 解决方案\r\n\r\n把扁平清单升级为持久化到磁盘的**任务图**。每个任务是一个 JSON 文件, 有状态、前置依赖 (`blockedBy`) 和后置依赖 (`blocks`)。任务图随时回答三个问题:\r\n\r\n- **什么可以做?** -- 状态为 `pending` 且 `blockedBy` 为空的任务。\r\n- **什么被卡住?** -- 等待前置任务完成的任务。\r\n- **什么做完了?** -- 状态为 `completed` 的任务, 完成时自动解锁后续任务。\r\n\r\n```\r\n.tasks/\r\n task_1.json {\"id\":1, \"status\":\"completed\"}\r\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\r\n\r\n任务图 (DAG):\r\n +----------+\r\n +--> | task 2 | --+\r\n | | pending | |\r\n+----------+ +----------+ +--> +----------+\r\n| task 1 | | task 4 |\r\n| completed| --> +----------+ +--> | blocked |\r\n+----------+ | task 3 | --+ +----------+\r\n | pending |\r\n +----------+\r\n\r\n顺序: task 1 必须先完成, 才能开始 2 和 3\r\n并行: task 2 和 3 可以同时执行\r\n依赖: task 4 要等 2 和 3 都完成\r\n状态: pending -> in_progress -> completed\r\n```\r\n\r\n这个任务图是 s07 之后所有机制的协调骨架: 后台执行 (s08)、多 agent 团队 (s09+)、worktree 隔离 (s12) 都读写这同一个结构。\r\n\r\n## 工作原理\r\n\r\n1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。使用 Jackson `ObjectMapper` 做 JSON 序列化。\r\n\r\n```java\r\npublic class TaskManager {\r\n private static final ObjectMapper MAPPER = new ObjectMapper();\r\n private final Path dir;\r\n private int nextId;\r\n\r\n public TaskManager(Path tasksDir) {\r\n this.dir = tasksDir;\r\n Files.createDirectories(dir);\r\n this.nextId = maxId() + 1;\r\n }\r\n\r\n @Tool(description = \"Create a new task with subject and optional description\")\r\n public String taskCreate(\r\n @ToolParam(description = \"Short subject of the task\") String subject,\r\n @ToolParam(description = \"Detailed description\", required = false) String description) {\r\n Map<String, Object> task = new LinkedHashMap<>();\r\n task.put(\"id\", nextId);\r\n task.put(\"subject\", subject);\r\n task.put(\"status\", \"pending\");\r\n task.put(\"blockedBy\", new ArrayList<>());\r\n task.put(\"blocks\", new ArrayList<>());\r\n save(task);\r\n nextId++;\r\n return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n }\r\n}\r\n```\r\n\r\n2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。\r\n\r\n```java\r\nprivate void clearDependency(int completedId) {\r\n try (Stream<Path> files = Files.list(dir)) {\r\n files.filter(f -> f.getFileName().toString().matches(\"task_\\\\d+\\\\.json\"))\r\n .forEach(f -> {\r\n Map<String, Object> task = MAPPER.readValue(\r\n Files.readString(f), new TypeReference<>() {});\r\n List<Integer> blockedBy = (List<Integer>) task.get(\"blockedBy\");\r\n if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {\r\n save(task);\r\n }\r\n });\r\n }\r\n}\r\n```\r\n\r\n3. **状态变更 + 依赖关联**: `taskUpdate` 处理状态转换和依赖边。当 status 变为 `completed` 时自动调用 `clearDependency`;`blockedBy`/`blocks` 是双向关系。\r\n\r\n```java\r\n@Tool(description = \"Update a task's status or dependencies.\")\r\npublic String taskUpdate(\r\n @ToolParam(description = \"Task ID\") int taskId,\r\n @ToolParam(description = \"New status\", required = false) String status,\r\n @ToolParam(description = \"Task IDs that block this task\", required = false) List<Integer> addBlockedBy,\r\n @Line truncated
},
{
"version": "s08",
"locale": "zh",
"title": "s08: Background Tasks (后台任务)",
"content": "# s08: Background Tasks (后台任务)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\n\n> *\"慢操作丢后台, agent 继续想下一步\"* -- 后台线程跑命令, 完成后注入通知。\n\n## 问题\n\n有些命令要跑好几分钟: `npm install`、`pytest`、`docker build`。阻塞式循环下模型只能干等。用户说 \"装依赖, 顺便建个配置文件\", 智能体却只能一个一个来。\n\n## 解决方案\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## 工作原理\n\n1. BackgroundManager 用线程安全的通知队列追踪任务。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` 启动守护线程, 立即返回。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. 子进程完成后, 结果进入通知队列。\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. 每次 LLM 调用前排空通知队列。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\n循环保持单线程。只有子进程 I/O 被并行化。\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|----------------|------------------|------------------------------------|\n| Tools | 8 | 6 (基础 + background_run + check) |\n| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |\n| 通知机制 | 无 | 每轮排空的队列 |\n| 并发 | 无 | 守护线程 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
"content": "# s08: Background Tasks (后台任务)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\r\n\r\n> *\"慢操作丢后台, agent 继续想下一步\"* -- 后台线程跑命令, 完成后注入通知。\r\n>\r\n> **Harness 层**: 后台执行 -- 模型继续思考, harness 负责等待。\r\n\r\n## 问题\r\n\r\n有些命令要跑好几分钟: `npm install`、`pytest`、`docker build`。阻塞式循环下模型只能干等。用户说 \"装依赖, 顺便建个配置文件\", 智能体却只能一个一个来。\r\n\r\n## 解决方案\r\n\r\n```\r\nMain thread Background thread\r\n+-----------------+ +-----------------+\r\n| agent loop | | subprocess runs |\r\n| ... | | ... |\r\n| [LLM call] <---+------- | enqueue(result) |\r\n| ^drain queue | +-----------------+\r\n+-----------------+\r\n\r\nTimeline:\r\nAgent --[spawn A]--[spawn B]--[other work]----\r\n | |\r\n v v\r\n [A runs] [B runs] (parallel)\r\n | |\r\n +-- results injected before next LLM call --+\r\n```\r\n\r\n## 工作原理\r\n\r\n1. BackgroundManager 用线程安全的并发容器追踪任务。Java 使用 `ConcurrentHashMap` 和 `CopyOnWriteArrayList` 代替 Python 的手动加锁。\r\n\r\n```java\r\npublic class BackgroundManager {\r\n private static final int TIMEOUT_SECONDS = 300;\r\n\r\n private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();\r\n private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();\r\n private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\r\n\r\n record TaskInfo(String status, String result, String command) {}\r\n public record Notification(String taskId, String status, String command, String result) {}\r\n}\r\n```\r\n\r\n2. `backgroundRun()` 提交虚拟线程 (Java 21), 立即返回。相比 Python 的 `daemon=True` 线程,虚拟线程更轻量、由 JVM 调度。\r\n\r\n```java\r\n@Tool(description = \"Run a command in a background thread. Returns task_id immediately without waiting.\")\r\npublic String backgroundRun(\r\n @ToolParam(description = \"The shell command to run in background\") String command) {\r\n String taskId = UUID.randomUUID().toString().substring(0, 8);\r\n tasks.put(taskId, new TaskInfo(\"running\", null, command));\r\n\r\n executor.submit(() -> execute(taskId, command));\r\n\r\n return \"Background task \" + taskId + \" started: \"\r\n + command.substring(0, Math.min(80, command.length()));\r\n}\r\n```\r\n\r\n3. 子进程完成后, 结果进入通知队列。使用 `ProcessBuilder` 执行命令,支持超时控制。\r\n\r\n```java\r\nprivate void execute(String taskId, String command) {\r\n String status, output;\r\n try {\r\n ProcessBuilder pb = new ProcessBuilder(\"sh\", \"-c\", command);\r\n pb.redirectErrorStream(true);\r\n Process process = pb.start();\r\n try (BufferedReader reader = new BufferedReader(\r\n new InputStreamReader(process.getInputStream()))) {\r\n output = reader.lines().collect(Collectors.joining(\"\\n\"));\r\n }\r\n boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);\r\n if (!finished) { process.destroyForcibly(); status = \"timeout\"; }\r\n else { status = \"completed\"; }\r\n } catch (Exception e) { output = \"Error: \" + e.getMessage(); status = \"error\"; }\r\n\r\n tasks.put(taskId, new TaskInfo(status, output, command));\r\n notificationQueue.add(new Notification(taskId, status, command, output));\r\n}\r\n```\r\n\r\n4. 每次用户输入时排空通知队列, 注入系统提示。Spring AI 的 `ChatClient` 管理内部工具循环, 因此改为在每次用户输入时 drain 通知并构建系统提示, 核心概念不变: fire and forget。\r\n\r\n```java\r\nAgentRunner.interactive(\"s08\", userMessage -> {\r\n // Drain 后台任务通知(对应 Python 中循环前的 drain_notifications)\r\n var notifs = bgManager.drainNotifications();\r\n String bgContext = \"\";\r\n if (!notifs.isEmpty()) {\r\n String notifText = notifs.stream()\r\n .map(n -> \"[bg:\" + n.taskId() + \"] \" + n.status() + \": \" + n.result())\r\n .collect(Collectors.joining(\"\\n\"));\r\n bgContext = \"\\n\\n<background-results>\\n\" + notifText + \"\\n</background-results>\";\r\n }\r\n\r\n String system = \"You are a coding agent. Use backgroundRun for long-running commands.\"\r\n + bgContext;\r\n\r\n ChatClient chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(system)\r\n .defaultTools(new BashTool(), new ReadFileTool(),\r\n new WriteFileTool(), new EditFileTool(), bgManager)\r\n .build();\r\n\r\n return chatCLine truncated
},
{
"version": "s09",
"locale": "zh",
"title": "s09: Agent Teams (智能体团队)",
"content": "# s09: Agent Teams (智能体团队)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\n\n> *\"任务太大一个人干不完, 要能分给队友\"* -- 持久化队友 + JSONL 邮箱。\n\n## 问题\n\n子智能体 (s04) 是一次性的: 生成、干活、返回摘要、消亡。没有身份, 没有跨调用的记忆。后台任务 (s08) 能跑 shell 命令, 但做不了 LLM 引导的决策。\n\n真正的团队协作需要三样东西: (1) 能跨多轮对话存活的持久智能体, (2) 身份和生命周期管理, (3) 智能体之间的通信通道。\n\n## 解决方案\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## 工作原理\n\n1. TeammateManager 通过 config.json 维护团队名册。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` 创建队友并在线程中启动 agent loop。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. 每个队友在每次 LLM 调用前检查收件箱, 将消息注入上下文。\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## 相对 s08 的变更\n\n| 组件 | 之前 (s08) | 之后 (s09) |\n|----------------|------------------|------------------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| 智能体数量 | 单一 | 领导 + N 个队友 |\n| 持久化 | 无 | config.json + JSONL 收件箱 |\n| 线程 | 后台命令 | 每线程完整 agent loop |\n| 生命周期 | 一次性 | idle -> working -> idle |\n| 通信 | 无 | message + broadcast |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. 输入 `/team` 查看团队名册和状态\n5. 输入 `/inbox` 手动检查领导的收件箱\n"
"content": "# s09: Agent Teams (智能体团队)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\r\n\r\n> *\"任务太大一个人干不完, 要能分给队友\"* -- 持久化队友 + JSONL 邮箱。\r\n>\r\n> **Harness 层**: 团队邮箱 -- 多个模型, 通过文件协调。\r\n\r\n## 问题\r\n\r\n子智能体 (s04) 是一次性的: 生成、干活、返回摘要、消亡。没有身份, 没有跨调用的记忆。后台任务 (s08) 能跑 shell 命令, 但做不了 LLM 引导的决策。\r\n\r\n真正的团队协作需要三样东西: (1) 能跨多轮对话存活的持久智能体, (2) 身份和生命周期管理, (3) 智能体之间的通信通道。\r\n\r\n## 解决方案\r\n\r\n```\r\nTeammate lifecycle:\r\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\r\n\r\nCommunication:\r\n .team/\r\n config.json <- team roster + statuses\r\n inbox/\r\n alice.jsonl <- append-only, drain-on-read\r\n bob.jsonl\r\n lead.jsonl\r\n\r\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\r\n | alice | -----------------------------> | bob |\r\n | loop | bob.jsonl << {json_line} | loop |\r\n +--------+ +--------+\r\n ^ |\r\n | BUS.read_inbox(\"alice\") |\r\n +---- alice.jsonl -> read + drain ---------+\r\n```\r\n\r\n## 工作原理\r\n\r\n1. TeammateManager 通过 config.json 维护团队名册。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s09/TeammateManager.java\r\npublic class TeammateManager {\r\n private final ChatModel chatModel;\r\n private final MessageBus bus;\r\n private final Path configPath;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n private Map<String, Object> config;\r\n // Python用threading.Thread + dict; Java用ConcurrentHashMap天然线程安全\r\n private final Map<String, Thread> threads = new ConcurrentHashMap<>();\r\n\r\n public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {\r\n this.chatModel = chatModel;\r\n this.bus = bus;\r\n this.configPath = teamDir.resolve(\"config.json\");\r\n Files.createDirectories(teamDir);\r\n this.config = loadConfig();\r\n }\r\n```\r\n\r\n2. `spawn()` 创建队友并在线程中启动 agent loop。\r\n\r\n```java\r\n// Python用threading.Thread; Java用Thread.startVirtualThread()虚拟线程\r\npublic synchronized String spawn(String name, String role, String prompt) {\r\n Map<String, Object> member = new LinkedHashMap<>();\r\n member.put(\"name\", name);\r\n member.put(\"role\", role);\r\n member.put(\"status\", \"working\");\r\n ((List<Map<String, Object>>) config.get(\"members\")).add(member);\r\n saveConfig();\r\n\r\n // 虚拟线程:轻量级,由JVM调度,不占用OS线程\r\n Thread thread = Thread.startVirtualThread(\r\n () -> teammateLoop(name, role, prompt));\r\n threads.put(name, thread);\r\n return \"Spawned '\" + name + \"' (role: \" + role + \")\";\r\n}\r\n```\r\n\r\n3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/core/team/MessageBus.java\r\n// Python靠GIL隐式保证线程安全; Java用synchronized显式保证\r\npublic class MessageBus {\r\n private final Path inboxDir;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n\r\n public synchronized String send(String sender, String to, String content,\r\n String msgType, Map<String, Object> extra) {\r\n Map<String, Object> msg = new LinkedHashMap<>();\r\n msg.put(\"type\", msgType);\r\n msg.put(\"from\", sender);\r\n msg.put(\"content\", content);\r\n msg.put(\"timestamp\", System.currentTimeMillis() / 1000.0);\r\n if (extra != null) msg.putAll(extra);\r\n\r\n Path inbox = inboxDir.resolve(to + \".jsonl\");\r\n Files.writeString(inbox, mapper.writeValueAsString(msg) + \"\\n\",\r\n StandardOpenOption.CREATE, StandardOpenOption.APPEND);\r\n return \"Sent \" + msgType + \" to \" + to;\r\n }\r\n\r\n public synchronized List<Map<String, Object>> readInbox(String name) {\r\n Path inbox = inboxDir.resolve(name + \".jsonl\");\r\n if (!Files.exists(inbox)) return List.of();\r\n List<Map<String, Object>> messages = new ArrayList<>();\r\n for (String line : Files.readAllLines(inbox)) {\r\n if (!line.isBlank())\r\n messages.add(mapper.readValue(line, new TypeReference<>() {}));\r\n }\r\n Files.writeString(inbox, \"\"); // drain\r\n return messages;\r\n }\r\n}\r\n```\r\n\r\n4. 每个队友在每次 `call()` 调用间检查收件箱Line truncated
},
{
"version": "s10",
"locale": "zh",
"title": "s10: Team Protocols (团队协议)",
"content": "# s10: Team Protocols (团队协议)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\n\n> *\"队友之间要有统一的沟通规矩\"* -- 一个 request-response 模式驱动所有协商。\n\n## 问题\n\ns09 中队友能干活能通信, 但缺少结构化协调:\n\n**关机**: 直接杀线程会留下写了一半的文件和过期的 config.json。需要握手 -- 领导请求, 队友批准 (收尾退出) 或拒绝 (继续干)。\n\n**计划审批**: 领导说 \"重构认证模块\", 队友立刻开干。高风险变更应该先过审。\n\n两者结构一样: 一方发带唯一 ID 的请求, 另一方引用同一 ID 响应。\n\n## 解决方案\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 工作原理\n\n1. 领导生成 request_id, 通过收件箱发起关机请求。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. 队友收到请求后, 用 approve/reject 响应。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\n一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。\n\n## 相对 s09 的变更\n\n| 组件 | 之前 (s09) | 之后 (s10) |\n|----------------|------------------|--------------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan) |\n| 关机 | 仅自然退出 | 请求-响应握手 |\n| 计划门控 | 无 | 提交/审查与审批 |\n| 关联 | 无 | 每个请求一个 request_id |\n| FSM | 无 | pending -> approved/rejected |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. 输入 `/team` 监控状态\n"
"content": "# s10: Team Protocols (团队协议)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\r\n\r\n> *\"队友之间要有统一的沟通规矩\"* -- 一个 request-response 模式驱动所有协商。\r\n>\r\n> **Harness 层**: 协议 -- 模型之间的结构化握手。\r\n\r\n## 问题\r\n\r\ns09 中队友能干活能通信, 但缺少结构化协调:\r\n\r\n**关机**: 直接杀线程会留下写了一半的文件和过期的 config.json。需要握手 -- 领导请求, 队友批准 (收尾退出) 或拒绝 (继续干)。\r\n\r\n**计划审批**: 领导说 \"重构认证模块\", 队友立刻开干。高风险变更应该先过审。\r\n\r\n两者结构一样: 一方发带唯一 ID 的请求, 另一方引用同一 ID 响应。\r\n\r\n## 解决方案\r\n\r\n```\r\nShutdown Protocol Plan Approval Protocol\r\n================== ======================\r\n\r\nLead Teammate Teammate Lead\r\n | | | |\r\n |--shutdown_req-->| |--plan_req------>|\r\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\r\n | | | |\r\n |<--shutdown_resp-| |<--plan_resp-----|\r\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\r\n | approve:true} | | approve:true} |\r\n\r\nShared FSM:\r\n [pending] --approve--> [approved]\r\n [pending] --reject---> [rejected]\r\n\r\nTrackers:\r\n shutdown_requests = {req_id: {target, status}}\r\n plan_requests = {req_id: {from, plan, status}}\r\n```\r\n\r\n## 工作原理\r\n\r\n1. 领导生成 request_id, 通过收件箱发起关机请求。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java\r\n// Python用字典 + threading.Lock; Java用ConcurrentHashMap天然线程安全\r\nprivate final ConcurrentHashMap<String, Map<String, String>> shutdownRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String handleShutdownRequest(String teammate) {\r\n String reqId = UUID.randomUUID().toString().substring(0, 8);\r\n shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(\r\n \"target\", teammate, \"status\", \"pending\")));\r\n bus.send(\"lead\", teammate, \"Please shut down gracefully.\",\r\n \"shutdown_request\", Map.of(\"request_id\", reqId));\r\n return \"Shutdown request \" + reqId + \" sent to '\" + teammate\r\n + \"' (status: pending)\";\r\n}\r\n```\r\n\r\n2. 队友收到请求后, 用 approve/reject 响应。\r\n\r\n```java\r\n// TeammateProtocolTool - 队友用@Tool注解响应关闭请求\r\n@Tool(description = \"Respond to a shutdown request\")\r\npublic String shutdownResponse(\r\n @ToolParam(description = \"The request_id\") String requestId,\r\n @ToolParam(description = \"true to approve\") boolean approve,\r\n @ToolParam(description = \"Reason for decision\") String reason) {\r\n return tracker.respondToShutdown(name, requestId, approve, reason);\r\n}\r\n\r\n// ProtocolTracker - 更新追踪器 + 发送响应消息\r\npublic String respondToShutdown(String sender, String requestId,\r\n boolean approve, String reason) {\r\n var req = shutdownRequests.get(requestId);\r\n if (req != null) {\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n }\r\n bus.send(sender, \"lead\", reason != null ? reason : \"\",\r\n \"shutdown_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve));\r\n return \"Shutdown \" + (approve ? \"approved\" : \"rejected\");\r\n}\r\n```\r\n\r\n3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。\r\n\r\n```java\r\n// ProtocolTracker - 同样的request_id关联模式,两种用途\r\nprivate final ConcurrentHashMap<String, Map<String, String>> planRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String reviewPlan(String requestId, boolean approve, String feedback) {\r\n var req = planRequests.get(requestId);\r\n if (req == null) return \"Error: Unknown plan request_id '\" + requestId + \"'\";\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n bus.send(\"lead\", req.get(\"from\"), feedback != null ? feedback : \"\",\r\n \"plan_approval_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve,\r\n \"feedback\", feedback != null ? feedback : \"\"));\r\n return \"Plan \" + req.get(\"status\") + \" for '\" + req.get(\"from\") + \"'\";\r\n}\r\n```\r\n\r\n一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。\r\n\r\n## 相对 s09 的变更\r\n\r\n| 组件 | 之前 (s09) | 之后 (s10) |\r\n|----------------|------------------|-----------------------------------Line truncated
},
{
"version": "s11",
"locale": "zh",
"title": "s11: Autonomous Agents (自治智能体)",
"content": "# s11: Autonomous Agents (自治智能体)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\n\n> *\"队友自己看看板, 有活就认领\"* -- 不需要领导逐个分配, 自组织。\n\n## 问题\n\ns09-s10 中, 队友只在被明确指派时才动。领导得给每个队友写 prompt, 任务看板上 10 个未认领的任务得手动分配。这扩展不了。\n\n真正的自治: 队友自己扫描任务看板, 认领没人做的任务, 做完再找下一个。\n\n一个细节: 上下文压缩 (s06) 后智能体可能忘了自己是谁。身份重注入解决这个问题。\n\n## 解决方案\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## 工作原理\n\n1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. 空闲阶段循环轮询收件箱和任务看板。\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。\n\n```python\ndef scan_unclaimed_tasks() -> list:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块。\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|----------------|------------------|----------------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| 自治性 | 领导指派 | 自组织 |\n| 空闲阶段 | 无 | 轮询收件箱 + 任务看板 |\n| 任务认领 | 仅手动 | 自动认领未分配任务 |\n| 身份 | 系统提示 | + 压缩后重注入 |\n| 超时 | 无 | 60 秒空闲 -> 自动关机 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s11_autonomous_agents.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`\n2. `Spawn a coder teammate and let it find work from the task board itself`\n3. `Create tasks with dependencies. Watch teammates respect the blocked order.`\n4. 输入 `/tasks` 查看带 owner 的任务看板\n5. 输入 `/team` 监控谁在工作、谁在空闲\Line truncated
"content": "# s11: Autonomous Agents (自治智能体)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\r\n\r\n> *\"队友自己看看板, 有活就认领\"* -- 不需要领导逐个分配, 自组织。\r\n>\r\n> **Harness 层**: 自治 -- 模型自己找活干, 无需指派。\r\n\r\n## 问题\r\n\r\ns09-s10 中, 队友只在被明确指派时才动。领导得给每个队友写 prompt, 任务看板上 10 个未认领的任务得手动分配。这扩展不了。\r\n\r\n真正的自治: 队友自己扫描任务看板, 认领没人做的任务, 做完再找下一个。\r\n\r\n一个细节: 上下文压缩 (s06) 后智能体可能忘了自己是谁。身份重注入解决这个问题。\r\n\r\n## 解决方案\r\n\r\n```\r\nTeammate lifecycle with idle cycle:\r\n\r\n+-------+\r\n| spawn |\r\n+---+---+\r\n |\r\n v\r\n+-------+ tool_use +-------+\r\n| WORK | <------------- | LLM |\r\n+---+---+ +-------+\r\n |\r\n | stop_reason != tool_use (or idle tool called)\r\n v\r\n+--------+\r\n| IDLE | poll every 5s for up to 60s\r\n+---+----+\r\n |\r\n +---> check inbox --> message? ----------> WORK\r\n |\r\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\r\n |\r\n +---> 60s timeout ----------------------> SHUTDOWN\r\n\r\nIdentity via system prompt (always present):\r\n ChatClient.builder(chatModel)\r\n .defaultSystem(identityPrompt) // 每次调用自动携带\r\n```\r\n\r\n## 工作原理\r\n\r\n1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java\r\n// AutonomousTeammateManager.autonomousLoop()\r\n\r\nprivate void autonomousLoop(String name, String role, String initialPrompt) {\r\n // idle标志:工具调用时设置,外部循环检测\r\n AtomicBoolean idleRequested = new AtomicBoolean(false);\r\n var idleTool = new IdleTool(idleRequested);\r\n\r\n ChatClient client = ChatClient.builder(chatModel)\r\n .defaultSystem(sysPrompt)\r\n .defaultTools(new BashTool(), new ReadFileTool(),\r\n new WriteFileTool(), new EditFileTool(),\r\n messageTool, protocolTool, idleTool, claimTool)\r\n .build();\r\n\r\n while (true) {\r\n // -- WORK PHASE --\r\n String nextMsg = initialPrompt;\r\n for (int round = 0; round < 50 && nextMsg != null; round++) {\r\n var inbox = bus.readInbox(name);\r\n // ... 合并收件箱消息到 nextMsg ...\r\n idleRequested.set(false);\r\n String response = client.prompt(sb.toString()).call().content();\r\n if (idleRequested.get()) break; // idle工具被调用\r\n nextMsg = null; // 后续轮次靠inbox驱动\r\n }\r\n\r\n // -- IDLE PHASE --\r\n setStatus(name, \"idle\");\r\n // ... 轮询收件箱 + 任务板(见下文) ...\r\n if (!resume) { setStatus(name, \"shutdown\"); return; }\r\n setStatus(name, \"working\");\r\n }\r\n}\r\n```\r\n\r\n2. 空闲阶段循环轮询收件箱和任务看板。\r\n\r\n```java\r\n// IDLE PHASE: 轮询收件箱 + 任务板\r\nsetStatus(name, \"idle\");\r\nboolean resume = false;\r\nint polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12\r\n\r\nfor (int p = 0; p < polls; p++) {\r\n Thread.sleep(POLL_INTERVAL * 1000L);\r\n\r\n // 检查收件箱\r\n var inbox = bus.readInbox(name);\r\n if (!inbox.isEmpty()) {\r\n initialPrompt = \"<inbox>\" + mapper.writeValueAsString(inbox) + \"</inbox>\";\r\n resume = true;\r\n break;\r\n }\r\n\r\n // 扫描任务板\r\n var unclaimed = scanUnclaimedTasks(tasksDir);\r\n if (!unclaimed.isEmpty()) {\r\n var task = unclaimed.get(0);\r\n int taskId = ((Number) task.get(\"id\")).intValue();\r\n claimTask(tasksDir, taskId, name);\r\n initialPrompt = String.format(\r\n \"<auto-claimed>Task #%d: %s\\n%s</auto-claimed>\",\r\n taskId, task.get(\"subject\"),\r\n task.getOrDefault(\"description\", \"\"));\r\n resume = true;\r\n break;\r\n }\r\n}\r\n\r\nif (!resume) { setStatus(name, \"shutdown\"); return; }\r\nsetStatus(name, \"working\");\r\n```\r\n\r\n3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。\r\n\r\n```java\r\nstatic List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {\r\n if (!Files.exists(tasksDir)) return List.of();\r\n List<Map<String, Object>> unclaimed = new ArrayList<>();\r\n ObjectMapper mapper = new ObjectMapper();\r\n try (var files = Files.list(tasksDir)) {\r\n files.filter(f -> f.getFileName().toString().startsWith(\"task_\")\r\n && f.getFileName().toString().endsWith(\".json\"))\r\n .sorted()\r\n .forEach(Line truncated
},
{
"version": "s12",
"locale": "zh",
"title": "s12: Worktree + Task Isolation (Worktree 任务隔离)",
"content": "# s12: Worktree + Task Isolation (Worktree 任务隔离)\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\n\n> *\"各干各的目录, 互不干扰\"* -- 任务管目标, worktree 管目录, 按 ID 绑定。\n\n## 问题\n\n到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `config.py`, B 也改 `config.py`, 未提交的改动互相污染, 谁也没法干净回滚。\n\n任务板管 \"做什么\" 但不管 \"在哪做\"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。\n\n## 解决方案\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## 工作原理\n\n1. **创建任务。** 先把目标持久化。\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`。\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\n绑定同时写入两侧状态:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **收尾。** 两种选择:\n - `worktree_keep(name)` -- 保留目录供后续使用。\n - `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\n事件类型: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。\n\n崩溃后从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的; 磁盘状态是持久的。\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| 协调 | 任务板 (owner/status) | 任务板 + worktree 显式绑定 |\n| 执行范围 | 共享目录 | 每个任务独立目录 |\n| 可恢复性 | 仅任务状态 | 任务状态 + worktree 索引 |\n| 收尾 | 任务完成 | 任务完成 + 显式 keep/remove |\n| 生命周期可见性 | 隐式日志 | `.worktrees/events.jsonl` 显式事件流 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s12_worktree_task_isolation.py\n```\n\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\n\n1. `Create tasks for backend auth and frontend login page, then list tasks.`\n2. `Create worktree \"auth-refactor\" for task 1, then bind task 2 to a new worktree \"ui-login\".`\n3. `Run \"git status --short\" in worktree \"auth-refactor\".`\n4. `Keep worktree \"ui-login\", then lLine truncated
"content": "# s12: Worktree + Task Isolation (Worktree 任务隔离)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\r\n\r\n> *\"各干各的目录, 互不干扰\"* -- 任务管目标, worktree 管目录, 按 ID 绑定。\r\n>\r\n> **Harness 层**: 目录隔离 -- 永不碰撞的并行执行通道。\r\n\r\n## 问题\r\n\r\n到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `Config.java`, B 也改 `Config.java`, 未提交的改动互相污染, 谁也没法干净回滚。\r\n\r\n任务板管 \"做什么\" 但不管 \"在哪做\"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。\r\n\r\n## 解决方案\r\n\r\n```\r\nControl plane (.tasks/) Execution plane (.worktrees/)\r\n+------------------+ +------------------------+\r\n| task_1.json | | auth-refactor/ |\r\n| status: in_progress <------> branch: wt/auth-refactor\r\n| worktree: \"auth-refactor\" | task_id: 1 |\r\n+------------------+ +------------------------+\r\n| task_2.json | | ui-login/ |\r\n| status: pending <------> branch: wt/ui-login\r\n| worktree: \"ui-login\" | task_id: 2 |\r\n+------------------+ +------------------------+\r\n |\r\n index.json (worktree registry)\r\n events.jsonl (lifecycle log)\r\n\r\nState machines:\r\n Task: pending -> in_progress -> completed\r\n Worktree: absent -> active -> removed | kept\r\n```\r\n\r\n## 工作原理\r\n\r\n1. **创建任务。** 先把目标持久化。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\ntasks.create(\"Implement auth refactor\", \"\");\r\n// -> .tasks/task_1.json status=pending worktree=\"\"\r\n```\r\n\r\n2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\nworktrees.create(\"auth-refactor\", 1, \"HEAD\");\r\n// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\r\n// -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\r\n```\r\n\r\n绑定同时写入两侧状态:\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\npublic String bindWorktree(int taskId, String worktree, String owner) {\r\n var task = load(taskId);\r\n task.put(\"worktree\", worktree);\r\n if (owner != null && !owner.isEmpty()) task.put(\"owner\", owner);\r\n if (\"pending\".equals(task.get(\"status\"))) task.put(\"status\", \"in_progress\");\r\n task.put(\"updated_at\", System.currentTimeMillis() / 1000.0);\r\n save(task);\r\n return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n}\r\n```\r\n\r\n3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()\r\nboolean isWindows = System.getProperty(\"os.name\").toLowerCase().contains(\"win\");\r\nProcessBuilder pb = isWindows\r\n ? new ProcessBuilder(\"cmd\", \"/c\", command)\r\n : new ProcessBuilder(\"sh\", \"-c\", command);\r\npb.directory(path.toFile());\r\npb.redirectErrorStream(true);\r\nProcess p = pb.start();\r\nString out = new String(p.getInputStream().readAllBytes()).trim();\r\nboolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);\r\n```\r\n\r\n4. **收尾。** 两种选择:\r\n - `worktree_keep(name)` -- 保留目录供后续使用。\r\n - `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\npublic String remove(String name, boolean force, boolean completeTask) {\r\n var wt = findWorktree(name);\r\n events.emit(\"worktree.remove.before\", ...);\r\n runGit(\"worktree\", \"remove\", wt.get(\"path\").toString());\r\n if (completeTask && wt.get(\"task_id\") != null) {\r\n int taskId = ((Number) wt.get(\"task_id\")).intValue();\r\n tasks.update(taskId, \"completed\", null);\r\n tasks.unbindWorktree(taskId);\r\n events.emit(\"task.completed\",\r\n Map.of(\"id\", taskId, \"status\", \"completed\"),\r\n Map.of(\"name\", name), null);\r\n }\r\n // 更新 index.json: status -> \"removed\"\r\n}\r\n```\r\n\r\n5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:\r\n\r\n```json\r\n{\r\n \"event\": \"worktree.remove.after\",\r\n \"task\": {\"id\": 1, \"status\": \"completed\"},\r\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\r\n \"ts\": 1730000000\r\n}\Line truncated
},
{
"version": "s01",
"locale": "ja",
"title": "s01: The Agent Loop",
"content": "# s01: The Agent Loop\n\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"One loop & Bash is all you need\"* -- 1つのツール + 1つのループ = エージェント。\n\n## 問題\n\n言語モデルはコードについて推論できるが、現実世界に触れられない。ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。\n\n## 解決策\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\n1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。\n\n## 仕組み\n\n1. ユーザーのプロンプトが最初のメッセージになる。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. メッセージとツール定義をLLMに送信する。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n1つの関数にまとめると:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\nこれでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。\n\n## 変更点\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n4. `Create a directory called test_output and write 3 files in it`\n"
"title": "s01: The Agent Loop (エージェントループ)",
"content": "# s01: The Agent Loop (エージェントループ)\r\n\r\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"One loop & Bash is all you need\"* -- 1つのツール + 1つのループ = エージェント。\r\n>\r\n> **Harness 層**: ループ -- モデルと現実世界を繋ぐ最初の接点。\r\n\r\n## 問題\r\n\r\n言語モデルはコードについて推論できるが、現実世界に触れられない -- ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびに手動で結果を貼り戻す必要がある。あなた自身がそのループになる。\r\n\r\n## 解決策\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tool |\r\n| prompt | | | | execute |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n (ChatClient.call() がツール呼び出しがなくなるまで自動ループ)\r\n```\r\n\r\n1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。\r\n\r\n## 仕組み\r\n\r\n### 1. ChatClient の構築:モデル注入 + ツール登録\r\n\r\nSpring Boot の自動設定で `ChatModel` を注入し、`ChatClient.builder()` でクライアントを構築、システムプロンプトとツールを設定する。\r\n\r\n```java\r\n// TIP: Python 版ではモジュールレベルで client = Anthropic() と MODEL を作成。\r\n// Spring AI は自動設定で ChatModel を注入し、builder で ChatClient を構築する。\r\npublic S01AgentLoop(ChatModel chatModel) {\r\n this.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent at \" + System.getProperty(\"user.dir\")\r\n + \". Use bash to solve tasks. Act, don't explain.\")\r\n .defaultTools(new BashTool()) // @Tool アノテーション付きツールオブジェクト\r\n .build();\r\n}\r\n```\r\n\r\n### 2. `@Tool` アノテーション:宣言的ツール登録\r\n\r\nSpring AI は `@Tool` アノテーションでツールを自動的に検出・登録する。起動時にフレームワークが `defaultTools()` に渡されたオブジェクトをスキャンし、すべての `@Tool` メソッドのシグネチャと説明を抽出し、LLM が必要とするツールスキーマ(名前、パラメータ、説明)を生成して、毎回の `call()` リクエストに自動的に含める。\r\n\r\n```java\r\n// BashTool -- Python 版の run_bash() 関数に相当\r\npublic class BashTool {\r\n @Tool(description = \"Run a shell command and return stdout + stderr\")\r\n public String bash(@ToolParam(description = \"The shell command to execute\")\r\n String command) {\r\n // 危険コマンドチェック + ProcessBuilder 実行 + タイムアウト制御 + 出力切り詰め\r\n // ...\r\n }\r\n}\r\n```\r\n\r\n> Python の手動登録方式との比較:\r\n> - Python: `TOOLS = [{\"name\": \"bash\", \"input_schema\": {...}}]` + `TOOL_HANDLERS = {\"bash\": run_bash}`\r\n> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化\r\n\r\n### 3. Spring AI 内部自動ループ:`call()` の内部実装\r\n\r\n**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:\r\n\r\n```python\r\n# Python 版 -- 手動ループ\r\ndef agent_loop(messages):\r\n while True:\r\n response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)\r\n # assistant メッセージを収集\r\n messages.append({\"role\": \"assistant\", \"content\": response.content})\r\n if response.stop_reason != \"tool_use\":\r\n return response # モデルがツールを呼ばなくなった、ループ終了\r\n # ツールを実行して結果を返送\r\n for block in response.content:\r\n if block.type == \"tool_use\":\r\n result = TOOL_HANDLERS[block.name](block.input)\r\n messages.append({\"role\": \"user\", \"content\": [{\"type\": \"tool_result\", ...}]})\r\n```\r\n\r\nSpring AI の `ChatClient.call()` は**完全に等価なロジックを内部にカプセル化**している:\r\n\r\n```\r\ncall() 内部フロー:\r\n ┌─────────────────────────────────────────────────────┐\r\n │ 1. リクエスト組み立て: system prompt + uLine truncated
},
{
"version": "s02",
"locale": "ja",
"title": "s02: Tool Use",
"content": "# s02: Tool Use\n\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"ツールを足すなら、ハンドラーを1つ足すだけ\"* -- ループは変わらない。新ツールは dispatch map に登録するだけ。\n\n## 問題\n\n`bash`だけでは、エージェントは何でもシェル経由で行う。`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file`や`write_file`のような専用ツールなら、ツールレベルでパスのサンドボックス化を強制できる。\n\n重要な点: ツールを追加してもループの変更は不要。\n\n## 解決策\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## 仕組み\n\n1. 各ツールにハンドラ関数を定義する。パスのサンドボックス化でワークスペース外への脱出を防ぐ。\n\n```python\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. ディスパッチマップがツール名とハンドラを結びつける。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\nツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない。\n\n## s01からの変更点\n\n| Component | Before (s01) | After (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (bash only) | 4 (bash, read, write, edit)|\n| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |\n| Path safety | None | `safe_path()` sandbox |\n| Agent loop | Unchanged | Unchanged |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
"title": "s02: Tool Use (ツール使用)",
"content": "# s02: Tool Use (ツール使用)\r\n\r\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"ツールを足すなら、@Tool メソッドを1つ足すだけ\"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。\r\n>\r\n> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。\r\n\r\n## 問題\r\n\r\n`bash` だけでは、すべての操作がシェル経由になる。`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。\r\n\r\n重要な洞察: ツールを追加してもループの変更は不要。\r\n\r\n## 解決策\r\n\r\n```\r\n+--------+ +-------+ +--------------------+\r\n| User | ---> | LLM | ---> | defaultTools() |\r\n| prompt | | | | { |\r\n+--------+ +---+---+ | BashTool |\r\n ^ | ReadFileTool |\r\n | | WriteFileTool |\r\n +-----------+ EditFileTool |\r\n tool_result | } |\r\n +--------------------+\r\n\r\nSpring AI が @Tool アノテーションで自動的に登録・分配する。\r\n手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。\r\n```\r\n\r\n## 仕組み\r\n\r\n1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator` がパスサンドボックスでワークスペース外への脱出を防ぐ。\r\n\r\n```java\r\n// PathValidator -- Python 版の safe_path() 関数に相当\r\npublic class PathValidator {\r\n private final Path workDir;\r\n\r\n public Path resolve(String relativePath) {\r\n Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();\r\n if (!resolved.startsWith(workDir)) {\r\n throw new IllegalArgumentException(\"Path escapes workspace: \" + relativePath);\r\n }\r\n return resolved;\r\n }\r\n}\r\n\r\n// ReadFileTool -- Python 版の run_read() 関数に相当\r\npublic class ReadFileTool {\r\n private final PathValidator pathValidator;\r\n\r\n @Tool(description = \"Read file contents. Optionally limit the number of lines returned.\")\r\n public String readFile(\r\n @ToolParam(description = \"Relative path to the file\") String path,\r\n @ToolParam(description = \"Maximum number of lines to read\", required = false) Integer limit) {\r\n Path filePath = pathValidator.resolve(path);\r\n List<String> lines = Files.readAllLines(filePath);\r\n if (limit != null && limit > 0 && limit < lines.size()) {\r\n lines = lines.subList(0, limit);\r\n }\r\n return String.join(\"\\n\", lines);\r\n }\r\n}\r\n```\r\n\r\n2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う。\r\n\r\n```java\r\n// Python 版の TOOL_HANDLERS 辞書に相当\r\n// Python: TOOL_HANDLERS = {\"bash\": fn, \"read_file\": fn, \"write_file\": fn, \"edit_file\": fn}\r\n// Java: ツールオブジェクトを渡すだけ、@Tool アノテーションで自動登録\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent ...\")\r\n .defaultTools(\r\n new BashTool(), // bash コマンド実行\r\n new ReadFileTool(), // ファイル読み取り\r\n new WriteFileTool(), // ファイル書き込み\r\n new EditFileTool() // ファイル編集(検索置換)\r\n )\r\n .build();\r\n```\r\n\r\n3. 呼び出しコードは s01 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する。\r\n\r\n```java\r\n// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと\r\n// ループコードは完全に同一 -- これが s02 の核心的な洞察\r\nAgentRunner.interactive(\"s02\", userMessage ->\r\n chatClient.prompt()\r\n .user(userMessage)\r\n .call()\r\n .content()\r\n);\r\n```\r\n\r\nツール追加 = `@Tool` クラスを1つ追加 + `defaultTools()` に渡す。ループは決して変わらない。\r\n\r\n> **TIPS — Python → Java 主要な適応ポイント:**\r\n> - Python の `TOOL_HANDLERS` 辞書 → Spring AI `@Tool` アノテーション + `defaultTools()` 自動登録・分配\r\n> - Python の `safe_path()` 関数 Line truncated
},
{
"version": "s03",
"locale": "ja",
"title": "s03: TodoWrite",
"content": "# s03: TodoWrite\n\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"計画のないエージェントは行き当たりばったり\"* -- まずステップを書き出し、それから実行。\n\n## 問題\n\nマルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。\n\n## 解決策\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 仕組み\n\n1. TodoManagerはアイテムのリストをステータス付きで保持する。`in_progress`にできるのは同時に1つだけ。\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む。\n\n## s02からの変更点\n\n| Component | Before (s02) | After (s03) |\n|----------------|------------------|----------------------------|\n| Tools | 4 | 5 (+todo) |\n| Planning | None | TodoManager with statuses |\n| Nag injection | None | `<reminder>` after 3 rounds|\n| Agent loop | Simple dispatch | + rounds_since_todo counter|\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
"title": "s03: TodoWrite (Todo書き込み)",
"content": "# s03: TodoWrite (Todo書き込み)\r\n\r\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"計画のないエージェントは行き当たりばったり\"* -- まずステップを書き出し、それから実行。完了率は倍増する。\r\n>\r\n> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。\r\n\r\n## 問題\r\n\r\nマルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。\r\n\r\n## 解決策\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tools |\r\n| prompt | | | | + todo |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n |\r\n +-----------+-----------+\r\n | TodoManager state |\r\n | [ ] task A |\r\n | [>] task B <- doing |\r\n | [x] task C |\r\n +-----------------------+\r\n |\r\n 毎回のリクエスト時に defaultSystem() で\r\n 最新の todo 状態をシステムプロンプトに注入\r\n```\r\n\r\n## 仕組み\r\n\r\n1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。\r\n\r\n```java\r\npublic class TodoManager {\r\n\r\n public record TodoItem(String id, String text, String status) {}\r\n\r\n private List<TodoItem> items = new ArrayList<>();\r\n\r\n @Tool(description = \"Update the full task list to track progress. \"\r\n + \"Each item must have id, text, status (pending/in_progress/completed). \"\r\n + \"Only one task can be in_progress at a time. Max 20 items.\")\r\n public String updateTodos(\r\n @ToolParam(description = \"The complete list of todo items\")\r\n List<TodoItem> items) {\r\n if (items.size() > 20) return \"Error: Max 20 todos allowed\";\r\n List<TodoItem> validated = new ArrayList<>();\r\n int inProgressCount = 0;\r\n for (TodoItem item : items) {\r\n String status = (item.status() != null)\r\n ? item.status().toLowerCase() : \"pending\";\r\n if (\"in_progress\".equals(status)) inProgressCount++;\r\n validated.add(new TodoItem(item.id(), item.text().trim(), status));\r\n }\r\n if (inProgressCount > 1)\r\n return \"Error: Only one task can be in_progress at a time\";\r\n this.items = validated;\r\n return render();\r\n }\r\n}\r\n```\r\n\r\n2. `TodoManager` は `defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。\r\n\r\n```java\r\nChatClient chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(system)\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n todoManager // @Tool アノテーションメソッドが自動登録\r\n )\r\n .build();\r\n```\r\n\r\n3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する。\r\n\r\n```java\r\n// 動的システムプロンプト: 現在の todo 状態を含む\r\nString system = \"You are a coding agent at \" + workDir + \".\\n\"\r\n + \"Use the todo tool to plan multi-step tasks. \"\r\n + \"Mark in_progress before starting, completed when done.\\n\"\r\n + \"IMPORTANT: You MUST call updateTodos regularly.\\n\\n\"\r\n + \"<current-todos>\\n\" + todoManager.render() + \"\\n</current-todos>\";\r\n```\r\n\r\n「同時に in_progress は1つだけ」の制約が逐次的な集中を強制する。システムプロンプトへの todo 状態の継続的な注入が説明責任を生む -- モデルは毎回自分の計画を見るため、更新を忘れない。\r\n\r\n> **TIP**: Python 版ではツールループ内で `rounds_since_todo` を追跡し、3ラウンド連続で todo を呼ばなかった場合に `<reminder>` テキストを注入する。Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内での注入はできない。そのため、システムプロンプト注Line truncated
},
{
"version": "s04",
"locale": "ja",
"title": "s04: Subagents",
"content": "# s04: Subagents\n\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを\"* -- サブエージェントは独立した messages[] を使い、メイン会話を汚さない。\n\n## 問題\n\nエージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。\n\n## 解決策\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## 仕組み\n\n1. 親に`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。\n\n## s03からの変更点\n\n| Component | Before (s03) | After (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (base) + task (parent) |\n| Context | Single shared | Parent + child isolation |\n| Subagent | None | `run_subagent()` function |\n| Return value | N/A | Summary text only |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
"title": "s04: Subagents (サブエージェント)",
"content": "# s04: Subagents (サブエージェント)\r\n\r\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを\"* -- サブエージェントは独立した messages[] を使い、メイン会話を汚さない。\r\n>\r\n> **Harness 層**: コンテキスト隔離 -- モデルの思考の明晰さを守る。\r\n\r\n## 問題\r\n\r\nエージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親エージェントに必要なのは「pytest」という一言だけだ。\r\n\r\n## 解決策\r\n\r\n```\r\nParent agent Subagent\r\n+------------------+ +------------------+\r\n| messages=[...] | | messages=[] | <-- fresh\r\n| | dispatch | |\r\n| tool: task | ----------> | while tool_use: |\r\n| prompt=\"...\" | | call tools |\r\n| | summary | append results |\r\n| result = \"...\" | <---------- | return last text |\r\n+------------------+ +------------------+\r\n\r\nParent context stays clean. Subagent context is discarded.\r\n```\r\n\r\n## 仕組み\r\n\r\n1. 親エージェントに `task` ツールを持たせる。子は `task` を除くすべての基本ツールを持つ(再帰的な生成は不可)。\r\n\r\n```java\r\n// 親 Agent: 基本ツール + SubagentTool を持つ\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent. \"\r\n + \"Use the task tool to delegate subtasks.\")\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n new SubagentTool(chatModel) // 親 Agent 専用\r\n )\r\n .build();\r\n```\r\n\r\n2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。\r\n\r\n```java\r\n@Tool(description = \"Spawn a subagent with fresh context. \"\r\n + \"Use for exploration or subtasks that might pollute the main context.\")\r\npublic String task(\r\n @ToolParam(description = \"The task prompt\") String prompt,\r\n @ToolParam(description = \"Short description\", required = false)\r\n String description) {\r\n\r\n // 新しい ChatClient を作成 -- これが「コンテキスト隔離」のすべて\r\n ChatClient subClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding subagent. \"\r\n + \"Complete the task, then summarize findings.\")\r\n .defaultTools( // 基本ツール、task なし(再帰防止)\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool()\r\n )\r\n .build();\r\n\r\n String result = subClient.prompt()\r\n .user(prompt)\r\n .call()\r\n .content();\r\n\r\n // 最終テキストだけを返し、子 Agent のコンテキストは破棄\r\n return (result != null) ? result : \"(no summary)\";\r\n}\r\n```\r\n\r\nサブエージェントは複数回のツール呼び出しを実行するかもしれないが、メッセージ履歴全体は破棄される。親が受け取るのは要約テキストだけで、通常の `tool_result` として返される。Spring AI の `ChatClient.call()` が内部でツールループを管理するため、手動でイテレーション回数を制限する必要はない。\r\n\r\n## s03 からの変更点\r\n\r\n| コンポーネント | 変更前 (s03) | 変更後 (s04) |\r\n|----------------|------------------|---------------------------------------|\r\n| Tools | 5 | 5 (基本) + SubagentTool (親側のみ) |\r\n| コンテキスト | 単一共有 | 親 + 子隔離 (独立した ChatClient) |\r\n| Subagent | なし | `SubagentTool.task()` メソッド |\r\n| 戻り値 | 該当なし | 要約テキストのみ |\r\n\r\n## 試してみる\r\n\r\n```sh\r\ncd learn-claude-code\r\nmvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent\r\n```\r\n\r\n以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):\r\n\r\n1. `Use a subtask to find what testing framework this prLine truncated
},
{
"version": "s05",
"locale": "ja",
"title": "s05: Skills",
"content": "# s05: Skills\n\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"必要な知識を、必要な時に読み込む\"* -- system prompt ではなく tool_result で注入。\n\n## 問題\n\nエージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークンを浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスクに無関係だ。\n\n## 解決策\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)。\n\n## 仕組み\n\n1. 各スキルは `SKILL.md` ファイルを含むディレクトリとして配置される。\n\n```\nskills/\n pdf/\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\n code-review/\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\n```\n\n2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.rglob(\"SKILL.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n name = meta.get(\"name\", f.parent.name)\n self.skills[name] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\nモデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)。\n\n## s04からの変更点\n\n| Component | Before (s04) | After (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (base + task) | 5 (base + load_skill) |\n| System prompt | Static string | + skill descriptions |\n| Knowledge | None | skills/\\*/SKILL.md files |\n| Injection | None | Two-layer (system + result)|\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
"title": "s05: Skills (スキルローディング)",
"content": "# s05: Skills (スキルローディング)\r\n\r\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"必要な知識を、必要な時に読み込む\"* -- system prompt ではなく tool_result で注入。\r\n>\r\n> **Harness 層**: オンデマンド知識 -- モデルが求めた時だけ渡すドメイン専門性。\r\n\r\n## 問題\r\n\r\nエージェントにドメイン固有のワークフローを遵守させたい: git の規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れるとトークンの浪費だ -- 10スキル x 2000トークン = 20,000トークン、大半が当面のタスクとは無関係。\r\n\r\n## 解決策\r\n\r\n```\r\nSystem prompt (Layer 1 -- always present):\r\n+--------------------------------------+\r\n| You are a coding agent. |\r\n| Skills available: |\r\n| - git: Git workflow helpers | ~100 tokens/skill\r\n| - test: Testing best practices |\r\n+--------------------------------------+\r\n\r\nWhen model calls load_skill(\"git\"):\r\n+--------------------------------------+\r\n| tool_result (Layer 2 -- on demand): |\r\n| <skill name=\"git\"> |\r\n| Full git workflow instructions... | ~2000 tokens\r\n| Step 1: ... |\r\n| </skill> |\r\n+--------------------------------------+\r\n```\r\n\r\n第1層: スキル名をシステムプロンプトに(低コスト)。第2層: 完全なコンテンツを tool_result でオンデマンド配信。\r\n\r\n## 仕組み\r\n\r\n1. 各スキルは `SKILL.md` ファイルを含むディレクトリで、YAML frontmatter 付き。\r\n\r\n```\r\nskills/\r\n pdf/\r\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\r\n code-review/\r\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\r\n```\r\n\r\n2. SkillLoader が `SKILL.md` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。\r\n\r\n```java\r\npublic class SkillLoader {\r\n\r\n private static final Pattern FRONTMATTER_PATTERN =\r\n Pattern.compile(\"^---\\\\n(.*?)\\\\n---\\\\n(.*)\", Pattern.DOTALL);\r\n\r\n private final Map<String, SkillInfo> skills = new LinkedHashMap<>();\r\n\r\n record SkillInfo(Map<String, String> meta, String body, String path) {}\r\n\r\n public SkillLoader(Path skillsDir) {\r\n loadAll(skillsDir);\r\n }\r\n\r\n /** skills ディレクトリ配下のすべての SKILL.md ファイルを再帰スキャン */\r\n private void loadAll(Path skillsDir) {\r\n if (!Files.exists(skillsDir)) return;\r\n try (Stream<Path> paths = Files.walk(skillsDir)) {\r\n paths.filter(p -> p.getFileName().toString().equals(\"SKILL.md\"))\r\n .sorted()\r\n .forEach(p -> {\r\n String text = Files.readString(p);\r\n var parsed = parseFrontmatter(text);\r\n String name = parsed.meta().getOrDefault(\"name\",\r\n p.getParent().getFileName().toString());\r\n skills.put(name, new SkillInfo(\r\n parsed.meta(), parsed.body(), p.toString()));\r\n });\r\n }\r\n }\r\n\r\n /** Layer 1: 全スキルの短い説明を取得(システムプロンプト注入用) */\r\n public String getDescriptions() {\r\n if (skills.isEmpty()) return \"(no skills available)\";\r\n StringBuilder sb = new StringBuilder();\r\n for (var entry : skills.entrySet()) {\r\n String desc = entry.getValue().meta()\r\n .getOrDefault(\"description\", \"No description\");\r\n sb.append(\" - \").append(entry.getKey())\r\n .append(\": \").append(desc).append(\"\\n\");\r\n }\r\n return sb.toString().stripTrailing();\r\n }\r\n\r\n /** Layer 2: 指定スキルの完全なコンテンツを読み込む(@Tool メソッドとして) */\r\n @Tool(description = \"Load specialized knowledge by name.\")\r\n public String loadSkill(\r\n @ToolParam(description = \"Skill name to load\") String name) {\r\n SkillInfo skill = skills.get(name);\r\n if (skill == null)\r\n return \"Error: Unknown skill '\" + name + \"'. Available: \"\r\n + String.join(\", \", skills.keySet());\r\n return \"<skill name=\\\"\" + name + \"\\\">\\n\"\r\n + skill.body() + \"\\n</skill>\";\r\n }\r\n}\r\n```\r\n\r\n3. 第1層はシステムプロンプトに配置。第2層は SkillLoader 上の `@Tool` アノテーションメソッドでオンデマンド読み込み。\r\n\r\n```java\r\npublic S05SkillLoading(ChatModel chatModel)Line truncated
},
{
"version": "s06",
"locale": "ja",
"title": "s06: Context Compact",
"content": "# s06: Context Compact\n\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"コンテキストはいつか溢れる、空ける手段が要る\"* -- 3層圧縮で無限セッションを実現。\n\n## 問題\n\nコンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。\n\n## 解決策\n\n積極性を段階的に上げる3層構成:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 仕組み\n\n1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。\n\n4. ループが3層すべてを統合する:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\nトランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。\n\n## s05からの変更点\n\n| Component | Before (s05) | After (s06) |\n|----------------|------------------|----------------------------|\n| Tools | 5 | 5 (base + compact) |\n| Context mgmt | None | Three-layer compression |\n| Micro-compact | None | Old results -> placeholders|\n| Auto-compact | None | Token threshold trigger |\n| Transcripts | None | Saved to .transcripts/ |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
"title": "s06: Context Compact (コンテキスト圧縮)",
"content": "# s06: Context Compact (コンテキスト圧縮)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"コンテキストはいつか溢れる、空ける手段が要る\"* -- 3層圧縮で無限セッションを実現。\r\n>\r\n> **Harness 層**: 圧縮 -- クリーンな記憶、無限のセッション。\r\n\r\n## 問題\r\n\r\nコンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模プロジェクトで作業できない。\r\n\r\n## 解決策\r\n\r\n積極性を段階的に上げる3層構成:\r\n\r\n```\r\nEvery turn:\r\n+------------------+\r\n| Tool call result |\r\n+------------------+\r\n |\r\n v\r\n[Layer 1: micro_compact] (silent, every turn)\r\n Replace tool_result > 3 turns old\r\n with \"[Previous: used {tool_name}]\"\r\n |\r\n v\r\n[Check: tokens > 50000?]\r\n | |\r\n no yes\r\n | |\r\n v v\r\ncontinue [Layer 2: auto_compact]\r\n Save transcript to .transcripts/\r\n LLM summarizes conversation.\r\n Replace all messages with [summary].\r\n |\r\n v\r\n [Layer 3: compact tool]\r\n Model calls compact explicitly.\r\n Same summarization as auto_compact.\r\n```\r\n\r\n## 仕組み\r\n\r\n1. **第1層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。\r\n\r\n```java\r\n/** トークン数の推定: 粗い見積もりで 4文字 ≈ 1トークン */\r\npublic int estimateTokens() {\r\n int chars = history.stream().mapToInt(t -> t.content().length()).sum();\r\n return chars / 4;\r\n}\r\n\r\n/** 会話履歴のサマリーを取得(システムプロンプト注入用、最近数ターンのみ保持) */\r\npublic String getContextSummary() {\r\n if (history.isEmpty()) return \"\";\r\n StringBuilder sb = new StringBuilder(\"\\n<conversation-context>\\n\");\r\n int start = Math.max(0, history.size() - KEEP_RECENT * 2);\r\n for (int i = start; i < history.size(); i++) {\r\n ConversationTurn turn = history.get(i);\r\n sb.append(\"[\").append(turn.role()).append(\"]: \")\r\n .append(turn.content(), 0, Math.min(500, turn.content().length()))\r\n .append(\"\\n\");\r\n }\r\n sb.append(\"</conversation-context>\");\r\n return sb.toString();\r\n}\r\n```\r\n\r\n2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。\r\n\r\n```java\r\npublic String compact() {\r\n // トランスクリプトをディスクに保存(完全な履歴は失われない)\r\n Files.createDirectories(transcriptDir);\r\n Path transcriptPath = transcriptDir.resolve(\r\n \"transcript_\" + System.currentTimeMillis() + \".jsonl\");\r\n try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {\r\n for (ConversationTurn turn : history) {\r\n writer.write(objectMapper.writeValueAsString(turn));\r\n writer.newLine();\r\n }\r\n }\r\n\r\n // LLM が要約を生成\r\n String conversationText = history.stream()\r\n .map(t -> t.role() + \": \" + t.content())\r\n .reduce(\"\", (a, b) -> a + \"\\n\" + b);\r\n if (conversationText.length() > 80000) {\r\n conversationText = conversationText.substring(0, 80000);\r\n }\r\n\r\n ChatClient summaryClient = ChatClient.builder(chatModel).build();\r\n String summary = summaryClient.prompt()\r\n .user(\"Summarize this conversation for continuity. Include: \"\r\n + \"1) What was accomplished, 2) Current state, \"\r\n + \"3) Key decisions.\\n\\n\" + conversationText)\r\n .call().content();\r\n\r\n // 要約で履歴を置換\r\n history.clear();\r\n history.add(new ConversationTurn(\"system\",\r\n \"[Conversation compressed. Transcript: \" + transcriptPath\r\n + \"]\\n\\n\" + summary));\r\n return summary;\r\n}\r\n```\r\n\r\n3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。\r\n\r\n```java\r\npublic class CompactTool {\r\n private final ContextCompactor compactor;\r\n\r\n public CompactTool(ContextCompactor compactor) {\r\n thiLine truncated
},
{
"version": "s07",
"locale": "ja",
"title": "s07: Task System",
"content": "# s07: Task System\n\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\n\n> *\"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する\"* -- ファイルベースのタスクグラフ、マルチエージェント協調の基盤。\n\n## 問題\n\ns03のTodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。\n\n明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮(s06)で消える。\n\n## 解決策\n\nフラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つのJSONファイルで、ステータス・前方依存(`blockedBy`)・後方依存(`blocks`)を持つ。タスクグラフは常に3つの問いに答える:\n\n- **何が実行可能か?** -- `pending`ステータスで`blockedBy`が空のタスク。\n- **何がブロックされているか?** -- 未完了の依存を待つタスク。\n- **何が完了したか?** -- `completed`のタスク。完了時に後続タスクを自動的にアンブロックする。\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\"}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\n\nタスクグラフ (DAG):\n +----------+\n +--> | task 2 | --+\n | | pending | |\n+----------+ +----------+ +--> +----------+\n| task 1 | | task 4 |\n| completed| --> +----------+ +--> | blocked |\n+----------+ | task 3 | --+ +----------+\n | pending |\n +----------+\n\n順序: task 1 は 2 と 3 より先に完了する必要がある\n並行: task 2 と 3 は同時に実行できる\n依存: task 4 は 2 と 3 の両方を待つ\nステータス: pending -> in_progress -> completed\n```\n\nこのタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行(s08)、マルチエージェントチーム(s09+)、worktree分離(s12)はすべてこの同じ構造を読み書きする。\n\n## 仕組み\n\n1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. 4つのタスクツールをディスパッチマップに追加する。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\ns07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。\n\n## s06からの変更点\n\n| コンポーネント | Before (s06) | After (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| 計画モデル | フラットチェックリスト (メモリ)Line truncated
"title": "s07: Task System (タスクシステム)",
"content": "# s07: Task System (タスクシステム)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する\"* -- ファイルベースのタスクグラフ、マルチエージェント協調の基盤。\r\n>\r\n> **Harness 層**: 永続タスク -- どの会話よりも長く生きる目標。\r\n\r\n## 問題\r\n\r\ns03 の TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク B はタスク A に依存し、タスク C と D は並行実行でき、タスク E は C と D の両方を待つ。\r\n\r\n明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮 (s06) で消える。\r\n\r\n## 解決策\r\n\r\nフラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つの JSON ファイルで、ステータス・前方依存 (`blockedBy`)・後方依存 (`blocks`) を持つ。タスクグラフは常に3つの問いに答える:\r\n\r\n- **何が実行可能か?** -- `pending` ステータスで `blockedBy` が空のタスク。\r\n- **何がブロックされているか?** -- 未完了の依存を待つタスク。\r\n- **何が完了したか?** -- `completed` のタスク。完了時に後続タスクを自動的にアンブロックする。\r\n\r\n```\r\n.tasks/\r\n task_1.json {\"id\":1, \"status\":\"completed\"}\r\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\r\n\r\nタスクグラフ (DAG):\r\n +----------+\r\n +--> | task 2 | --+\r\n | | pending | |\r\n+----------+ +----------+ +--> +----------+\r\n| task 1 | | task 4 |\r\n| completed| --> +----------+ +--> | blocked |\r\n+----------+ | task 3 | --+ +----------+\r\n | pending |\r\n +----------+\r\n\r\n順序: task 1 は 2 と 3 より先に完了する必要がある\r\n並行: task 2 と 3 は同時に実行できる\r\n依存: task 4 は 2 と 3 の両方を待つ\r\nステータス: pending -> in_progress -> completed\r\n```\r\n\r\nこのタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行 (s08)、マルチエージェントチーム (s09+)、worktree 分離 (s12) はすべてこの同じ構造を読み書きする。\r\n\r\n## 仕組み\r\n\r\n1. **TaskManager**: タスクごとに1つの JSON ファイル、依存グラフ付き CRUD。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。\r\n\r\n```java\r\npublic class TaskManager {\r\n private static final ObjectMapper MAPPER = new ObjectMapper();\r\n private final Path dir;\r\n private int nextId;\r\n\r\n public TaskManager(Path tasksDir) {\r\n this.dir = tasksDir;\r\n Files.createDirectories(dir);\r\n this.nextId = maxId() + 1;\r\n }\r\n\r\n @Tool(description = \"Create a new task with subject and optional description\")\r\n public String taskCreate(\r\n @ToolParam(description = \"Short subject of the task\") String subject,\r\n @ToolParam(description = \"Detailed description\", required = false) String description) {\r\n Map<String, Object> task = new LinkedHashMap<>();\r\n task.put(\"id\", nextId);\r\n task.put(\"subject\", subject);\r\n task.put(\"status\", \"pending\");\r\n task.put(\"blockedBy\", new ArrayList<>());\r\n task.put(\"blocks\", new ArrayList<>());\r\n save(task);\r\n nextId++;\r\n return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n }\r\n}\r\n```\r\n\r\n2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。\r\n\r\n```java\r\nprivate void clearDependency(int completedId) {\r\n try (Stream<Path> files = Files.list(dir)) {\r\n files.filter(f -> f.getFileName().toString().matches(\"task_\\\\d+\\\\.json\"))\r\n .forEach(f -> {\r\n Map<String, Object> task = MAPPER.readValue(\r\n Files.readString(f), new TypeReference<>() {});\r\n List<Integer> blockedBy = (List<Integer>) task.get(\"blockedBy\");\r\n if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {\r\nLine truncated
},
{
"version": "s08",
"locale": "ja",
"title": "s08: Background Tasks",
"content": "# s08: Background Tasks\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\n\n> *\"遅い操作はバックグラウンドへ、エージェントは次を考え続ける\"* -- デーモンスレッドがコマンド実行、完了後に通知を注入。\n\n## 問題\n\n一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する。\n\n## 解決策\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## 仕組み\n\n1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()`がデーモンスレッドを開始し、即座にリターンする。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. サブプロセス完了時に、結果を通知キューへ。\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. エージェントループが各LLM呼び出しの前に通知をドレインする。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\nループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。\n\n## s07からの変更点\n\n| Component | Before (s07) | After (s08) |\n|----------------|------------------|----------------------------|\n| Tools | 8 | 6 (base + background_run + check)|\n| Execution | Blocking only | Blocking + background threads|\n| Notification | None | Queue drained per loop |\n| Concurrency | None | Daemon threads |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
"title": "s08: Background Tasks (バックグラウンドタスク)",
"content": "# s08: Background Tasks (バックグラウンドタスク)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\r\n\r\n> *\"遅い操作はバックグラウンドへ、エージェントは次を考え続ける\"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。\r\n>\r\n> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。\r\n\r\n## 問題\r\n\r\n一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない。\r\n\r\n## 解決策\r\n\r\n```\r\nMain thread Background thread\r\n+-----------------+ +-----------------+\r\n| agent loop | | subprocess runs |\r\n| ... | | ... |\r\n| [LLM call] <---+------- | enqueue(result) |\r\n| ^drain queue | +-----------------+\r\n+-----------------+\r\n\r\nTimeline:\r\nAgent --[spawn A]--[spawn B]--[other work]----\r\n | |\r\n v v\r\n [A runs] [B runs] (parallel)\r\n | |\r\n +-- results injected before next LLM call --+\r\n```\r\n\r\n## 仕組み\r\n\r\n1. BackgroundManager がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap` と `CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。\r\n\r\n```java\r\npublic class BackgroundManager {\r\n private static final int TIMEOUT_SECONDS = 300;\r\n\r\n private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();\r\n private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();\r\n private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\r\n\r\n record TaskInfo(String status, String result, String command) {}\r\n public record Notification(String taskId, String status, String command, String result) {}\r\n}\r\n```\r\n\r\n2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。\r\n\r\n```java\r\n@Tool(description = \"Run a command in a background thread. Returns task_id immediately without waiting.\")\r\npublic String backgroundRun(\r\n @ToolParam(description = \"The shell command to run in background\") String command) {\r\n String taskId = UUID.randomUUID().toString().substring(0, 8);\r\n tasks.put(taskId, new TaskInfo(\"running\", null, command));\r\n\r\n executor.submit(() -> execute(taskId, command));\r\n\r\n return \"Background task \" + taskId + \" started: \"\r\n + command.substring(0, Math.min(80, command.length()));\r\n}\r\n```\r\n\r\n3. サブプロセス完了時に、結果が通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート。\r\n\r\n```java\r\nprivate void execute(String taskId, String command) {\r\n String status, output;\r\n try {\r\n ProcessBuilder pb = new ProcessBuilder(\"sh\", \"-c\", command);\r\n pb.redirectErrorStream(true);\r\n Process process = pb.start();\r\n try (BufferedReader reader = new BufferedReader(\r\n new InputStreamReader(process.getInputStream()))) {\r\n output = reader.lines().collect(Collectors.joining(\"\\n\"));\r\n }\r\n boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);\r\n if (!finished) { process.destroyForcibly(); status = \"timeout\"; }\r\n else { status = \"completed\"; }\r\n } catch (Exception e) { output = \"Error: \" + e.getMessage(); status = \"error\"; }\r\n\r\n tasks.put(taskId, new TaskInfo(status, output, command));\r\n notificationQueue.add(new Notification(taskId, status, command, output));\r\n}\r\n```\r\n\r\n4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget。\r\n\r\n```java\r\nAgentRunner.interactive(\"s08\", userMessage -> {\r\n // バックグラウンドタスク通知をドレイン(Python のループ前 drain_notifications に相当)\r\n var notifs = bgManager.drainNotifications();\r\n String bgContext = \"\";\r\n if (!notifs.isEmpty()) {\r\n String notifText = notifs.stream()\r\n .map(n -> \"[bg:\" + n.taskId() + \"] \" + n.status() + \": \" + n.resuLine truncated
},
{
"version": "s09",
"locale": "ja",
"title": "s09: Agent Teams",
"content": "# s09: Agent Teams\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\n\n> *\"一人で終わらないなら、チームメイトに任せる\"* -- 永続チームメイト + 非同期メールボックス。\n\n## 問題\n\nサブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。\n\n本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ。\n\n## 解決策\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## 仕組み\n\n1. TeammateManagerがconfig.jsonでチーム名簿を管理する。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## s08からの変更点\n\n| Component | Before (s08) | After (s09) |\n|----------------|------------------|----------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| Agents | Single | Lead + N teammates |\n| Persistence | None | config.json + JSONL inboxes|\n| Threads | Background cmds | Full agent loops per thread|\n| Lifecycle | Fire-and-forget | idle -> working -> idle |\n| Communication | None | message + broadcast |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. `/team`と入力してスLine truncated
"title": "s09: Agent Teams (エージェントチーム)",
"content": "# s09: Agent Teams (エージェントチーム)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\r\n\r\n> *\"一人で終わらないなら、チームメイトに任せる\"* -- 永続チームメイト + JSONL メールボックス。\r\n>\r\n> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。\r\n\r\n## 問題\r\n\r\nサブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。\r\n\r\n本物のチームワークには3つが必要: (1) 複数ターンの会話を超えて存続する永続エージェント、(2) アイデンティティとライフサイクル管理、(3) エージェント間の通信チャネル。\r\n\r\n## 解決策\r\n\r\n```\r\nTeammate lifecycle:\r\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\r\n\r\nCommunication:\r\n .team/\r\n config.json <- team roster + statuses\r\n inbox/\r\n alice.jsonl <- append-only, drain-on-read\r\n bob.jsonl\r\n lead.jsonl\r\n\r\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\r\n | alice | -----------------------------> | bob |\r\n | loop | bob.jsonl << {json_line} | loop |\r\n +--------+ +--------+\r\n ^ |\r\n | BUS.read_inbox(\"alice\") |\r\n +---- alice.jsonl -> read + drain ---------+\r\n```\r\n\r\n## 仕組み\r\n\r\n1. TeammateManager が config.json でチーム名簿を管理する。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s09/TeammateManager.java\r\npublic class TeammateManager {\r\n private final ChatModel chatModel;\r\n private final MessageBus bus;\r\n private final Path configPath;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n private Map<String, Object> config;\r\n // Python は threading.Thread + dict を使用、Java は ConcurrentHashMap で天然スレッドセーフ\r\n private final Map<String, Thread> threads = new ConcurrentHashMap<>();\r\n\r\n public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {\r\n this.chatModel = chatModel;\r\n this.bus = bus;\r\n this.configPath = teamDir.resolve(\"config.json\");\r\n Files.createDirectories(teamDir);\r\n this.config = loadConfig();\r\n }\r\n```\r\n\r\n2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。\r\n\r\n```java\r\n// Python は threading.Thread を使用、Java は Thread.startVirtualThread() 仮想スレッドを使用\r\npublic synchronized String spawn(String name, String role, String prompt) {\r\n Map<String, Object> member = new LinkedHashMap<>();\r\n member.put(\"name\", name);\r\n member.put(\"role\", role);\r\n member.put(\"status\", \"working\");\r\n ((List<Map<String, Object>>) config.get(\"members\")).add(member);\r\n saveConfig();\r\n\r\n // 仮想スレッド: 軽量、JVM スケジュール、OS スレッドを占有しない\r\n Thread thread = Thread.startVirtualThread(\r\n () -> teammateLoop(name, role, prompt));\r\n threads.put(name, thread);\r\n return \"Spawned '\" + name + \"' (role: \" + role + \")\";\r\n}\r\n```\r\n\r\n3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/core/team/MessageBus.java\r\n// Python は GIL で暗黙的にスレッドセーフ、Java は synchronized で明示的に保証\r\npublic class MessageBus {\r\n private final Path inboxDir;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n\r\n public synchronized String send(String sender, String to, String content,\r\n String msgType, Map<String, Object> extra) {\r\n Map<String, Object> msg = new LinkedHashMap<>();\r\n msg.put(\"type\", msgType);\r\n msg.put(\"from\", sender);\r\n msg.put(\"content\", content);\r\n msg.put(\"timestamp\", System.currentTimeMillis() / 1000.0);\r\n if (extra != null) msg.putAll(extra);\r\n\r\n Path inbox = inboxDir.resolve(to + \".jsonl\");\r\n Files.writeString(inbox, mapper.writeValueAsString(msg) + \"\\n\",\r\n StandardOpenOption.CREATE, StandardOpenOption.APPEND);\r\n return \"Sent \" + msgType + \" to \" + to;\r\n }\r\n\r\n public synchronized List<Map<String, Object>> readInbox(String name) {\r\n Path inbox = inboxDir.resolve(name + \".jsonl\");\Line truncated
},
{
"version": "s10",
"locale": "ja",
"title": "s10: Team Protocols",
"content": "# s10: Team Protocols\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\n\n> *\"チームメイト間には統一の通信ルールが必要\"* -- 1つの request-response パターンが全交渉を駆動。\n\n## 問題\n\ns09ではチームメイトが作業し通信するが、構造化された協調がない:\n\n**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。\n\n**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。\n\n両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。\n\n## 解決策\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 仕組み\n\n1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. チームメイトがリクエストを受信し、承認または拒否で応答する。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)。\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\n1つのFSM、2つの応用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。\n\n## s09からの変更点\n\n| Component | Before (s09) | After (s10) |\n|----------------|------------------|------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan)|\n| Shutdown | Natural exit only| Request-response handshake |\n| Plan gating | None | Submit/review with approval |\n| Correlation | None | request_id per request |\n| FSM | None | pending -> approved/rejected |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. `/team`と入力してステータスを監視する\n"
"title": "s10: Team Protocols (チームプロトコル)",
"content": "# s10: Team Protocols (チームプロトコル)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\r\n\r\n> *\"チームメイト間には統一の通信ルールが必要\"* -- 1つの request-response パターンが全交渉を駆動。\r\n>\r\n> **Harness 層**: プロトコル -- モデル間の構造化されたハンドシェイク。\r\n\r\n## 問題\r\n\r\ns09 ではチームメイトが作業し通信するが、構造化された協調がない:\r\n\r\n**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.json が不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。\r\n\r\n**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にレビューすべきだ。\r\n\r\n両方とも同じ構造: 一方がユニーク ID を持つリクエストを送り、他方がその ID で応答する。\r\n\r\n## 解決策\r\n\r\n```\r\nShutdown Protocol Plan Approval Protocol\r\n================== ======================\r\n\r\nLead Teammate Teammate Lead\r\n | | | |\r\n |--shutdown_req-->| |--plan_req------>|\r\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\r\n | | | |\r\n |<--shutdown_resp-| |<--plan_resp-----|\r\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\r\n | approve:true} | | approve:true} |\r\n\r\nShared FSM:\r\n [pending] --approve--> [approved]\r\n [pending] --reject---> [rejected]\r\n\r\nTrackers:\r\n shutdown_requests = {req_id: {target, status}}\r\n plan_requests = {req_id: {from, plan, status}}\r\n```\r\n\r\n## 仕組み\r\n\r\n1. リーダーが request_id を生成し、インボックス経由でシャットダウンを開始する。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java\r\n// Python は辞書 + threading.Lock を使用、Java は ConcurrentHashMap で天然スレッドセーフ\r\nprivate final ConcurrentHashMap<String, Map<String, String>> shutdownRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String handleShutdownRequest(String teammate) {\r\n String reqId = UUID.randomUUID().toString().substring(0, 8);\r\n shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(\r\n \"target\", teammate, \"status\", \"pending\")));\r\n bus.send(\"lead\", teammate, \"Please shut down gracefully.\",\r\n \"shutdown_request\", Map.of(\"request_id\", reqId));\r\n return \"Shutdown request \" + reqId + \" sent to '\" + teammate\r\n + \"' (status: pending)\";\r\n}\r\n```\r\n\r\n2. チームメイトがリクエストを受信し、承認または拒否で応答する。\r\n\r\n```java\r\n// TeammateProtocolTool - チームメイトが @Tool アノテーションでシャットダウン要求に応答\r\n@Tool(description = \"Respond to a shutdown request\")\r\npublic String shutdownResponse(\r\n @ToolParam(description = \"The request_id\") String requestId,\r\n @ToolParam(description = \"true to approve\") boolean approve,\r\n @ToolParam(description = \"Reason for decision\") String reason) {\r\n return tracker.respondToShutdown(name, requestId, approve, reason);\r\n}\r\n\r\n// ProtocolTracker - トラッカー更新 + レスポンスメッセージ送信\r\npublic String respondToShutdown(String sender, String requestId,\r\n boolean approve, String reason) {\r\n var req = shutdownRequests.get(requestId);\r\n if (req != null) {\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n }\r\n bus.send(sender, \"lead\", reason != null ? reason : \"\",\r\n \"shutdown_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve));\r\n return \"Shutdown \" + (approve ? \"approved\" : \"rejected\");\r\n}\r\n```\r\n\r\n3. プラン承認もまったく同じパターン。チームメイトがプランを提出(request_id を生成)、リーダーがレビュー(同じ request_id を参照)。\r\n\r\n```java\r\n// ProtocolTracker - 同じ request_id 関連パターン、2つの用途\r\nprivate final ConcurrentHashMap<String, Map<String, String>> planRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String reviewPlan(String requestId, boolean approve, String feedback) {\r\n var req = planRequests.get(requestId);\r\n if (req == null) return \"Error: Unknown plan request_id '\" + requestId + \"'\";\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n bus.send(\"lead\", req.get(\"from\"), feedback != nLine truncated
},
{
"version": "s11",
"locale": "ja",
"title": "s11: Autonomous Agents",
"content": "# s11: Autonomous Agents\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\n\n> *\"チームメイトが自らボードを見て、仕事を取る\"* -- リーダーが逐一割り振る必要はない。\n\n## 問題\n\ns09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。\n\n真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。\n\nもう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。\n\n## 解決策\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## 仕組み\n\n1. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. IDLEフェーズがインボックスとタスクボードをポーリングする。\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。\n\n```python\ndef scan_unclaimed_tasks() -> list:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する。\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## s10からの変更点\n\n| Component | Before (s10) | After (s11) |\n|----------------|------------------|----------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| Autonomy | Lead-directed | Self-organizing |\n| Idle phase | None | Poll inbox + task board |\n| Task claiming | Manual only | Auto-claim unclaimed tasks |\n| Identity | System prompt | + re-injection after compress|\n| Timeout | None | 60s idle -> auto shutdown |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npythoLine truncated
"title": "s11: Autonomous Agents (自律エージェント)",
"content": "# s11: Autonomous Agents (自律エージェント)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\r\n\r\n> *\"チームメイトが自らボードを見て、仕事を取る\"* -- リーダーが逐一割り振る必要はない、自己組織化。\r\n>\r\n> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。\r\n\r\n## 問題\r\n\r\ns09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトにプロンプトを書き、タスクボード上の10個の未割り当てタスクを手動で割り当てる。これはスケールしない。\r\n\r\n真の自律性: チームメイトが自分でタスクボードをスキャンし、未確保のタスクを確保し、完了したら次を探す。\r\n\r\nもう1つの問題: コンテキスト圧縮 (s06) 後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。\r\n\r\n## 解決策\r\n\r\n```\r\nTeammate lifecycle with idle cycle:\r\n\r\n+-------+\r\n| spawn |\r\n+---+---+\r\n |\r\n v\r\n+-------+ tool_use +-------+\r\n| WORK | <------------- | LLM |\r\n+---+---+ +-------+\r\n |\r\n | stop_reason != tool_use (or idle tool called)\r\n v\r\n+--------+\r\n| IDLE | poll every 5s for up to 60s\r\n+---+----+\r\n |\r\n +---> check inbox --> message? ----------> WORK\r\n |\r\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\r\n |\r\n +---> 60s timeout ----------------------> SHUTDOWN\r\n\r\nIdentity via system prompt (always present):\r\n ChatClient.builder(chatModel)\r\n .defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与\r\n```\r\n\r\n## 仕組み\r\n\r\n1. チームメイトのループは WORK と IDLE の2フェーズ。LLM がツール呼び出しを止めた時(または `idle` ツールを呼んだ時)、IDLE フェーズに入る。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java\r\n// AutonomousTeammateManager.autonomousLoop()\r\n\r\nprivate void autonomousLoop(String name, String role, String initialPrompt) {\r\n // idle フラグ: ツール呼び出し時に設定、外部ループが検出\r\n AtomicBoolean idleRequested = new AtomicBoolean(false);\r\n var idleTool = new IdleTool(idleRequested);\r\n\r\n ChatClient client = ChatClient.builder(chatModel)\r\n .defaultSystem(sysPrompt)\r\n .defaultTools(new BashTool(), new ReadFileTool(),\r\n new WriteFileTool(), new EditFileTool(),\r\n messageTool, protocolTool, idleTool, claimTool)\r\n .build();\r\n\r\n while (true) {\r\n // -- WORK PHASE --\r\n String nextMsg = initialPrompt;\r\n for (int round = 0; round < 50 && nextMsg != null; round++) {\r\n var inbox = bus.readInbox(name);\r\n // ... インボックスメッセージを nextMsg にマージ ...\r\n idleRequested.set(false);\r\n String response = client.prompt(sb.toString()).call().content();\r\n if (idleRequested.get()) break; // idle ツールが呼ばれた\r\n nextMsg = null; // 以降のラウンドは inbox 駆動\r\n }\r\n\r\n // -- IDLE PHASE --\r\n setStatus(name, \"idle\");\r\n // ... インボックス + タスクボードをポーリング(下記参照) ...\r\n if (!resume) { setStatus(name, \"shutdown\"); return; }\r\n setStatus(name, \"working\");\r\n }\r\n}\r\n```\r\n\r\n2. IDLE フェーズがインボックスとタスクボードをポーリングする。\r\n\r\n```java\r\n// IDLE PHASE: インボックス + タスクボードをポーリング\r\nsetStatus(name, \"idle\");\r\nboolean resume = false;\r\nint polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12\r\n\r\nfor (int p = 0; p < polls; p++) {\r\n Thread.sleep(POLL_INTERVAL * 1000L);\r\n\r\n // インボックスをチェック\r\n var inbox = bus.readInbox(name);\r\n if (!inbox.isEmpty()) {\r\n initialPrompt = \"<inbox>\" + mapper.writeValueAsString(inbox) + \"</inbox>\";\r\n resume = true;\r\n break;\r\n }\r\n\r\n // タスクボードをスキャン\r\n var unclaimed = scanUnclaimedTasks(tasksDir);\r\n if (!unclaimed.isEmpty()) {\r\n var task = unclaimed.get(0);\r\n int taskId = ((Number) task.get(\"id\")).intValue();\r\n claimTask(tasksDir, taskId, name);\r\n initialPrompt = String.format(\r\n \"<auto-claimed>Task #%d: %s\\n%s</auto-claimed>\",\r\n taskId, task.get(\"subject\"),\r\n task.getOrDefault(\"description\", \"\"));\r\n resume = true;\r\n break;\r\n }\r\n}\r\n\r\nif (!resume) { setStatus(name, \"shutdown\"); return; }\r\nsetStatus(name, \"working\");\r\n``Line truncated
},
{
"version": "s12",
"locale": "ja",
"title": "s12: Worktree + Task Isolation",
"content": "# s12: Worktree + Task Isolation\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\n\n> *\"各自のディレクトリで作業し、互いに干渉しない\"* -- タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け。\n\n## 問題\n\ns11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。\n\nタスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。\n\n## 解決策\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## 仕組み\n\n1. **タスクを作成する。** まず目標を永続化する。\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\n紐付けは両側に状態を書き込む:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **終了処理。** 2つの選択肢:\n - `worktree_keep(name)` -- ディレクトリを保持する。\n - `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\n発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。\n\nクラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。\n\n## s11からの変更点\n\n| Component | Before (s11) | After (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| Coordination | Task board (owner/status) | Task board + explicit worktree binding |\n| Execution scope | Shared directory | Task-scoped isolated directory |\n| Recoverability | Task status only | Task status + worktree index |\n| Teardown Line truncated
"title": "s12: Worktree + Task Isolation (Worktree タスク隔離)",
"content": "# s12: Worktree + Task Isolation (Worktree タスク隔離)\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\r\n\r\n> *\"各自のディレクトリで作業し、互いに干渉しない\"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。\r\n>\r\n> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。\r\n\r\n## 問題\r\n\r\ns11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。\r\n\r\nタスクボードは「何をやるか」を追跡するが「どこでやるか」には関知しない。解決策: 各タスクに独立した git worktree ディレクトリを与え、タスク ID で両者を関連付ける。\r\n\r\n## 解決策\r\n\r\n```\r\nControl plane (.tasks/) Execution plane (.worktrees/)\r\n+------------------+ +------------------------+\r\n| task_1.json | | auth-refactor/ |\r\n| status: in_progress <------> branch: wt/auth-refactor\r\n| worktree: \"auth-refactor\" | task_id: 1 |\r\n+------------------+ +------------------------+\r\n| task_2.json | | ui-login/ |\r\n| status: pending <------> branch: wt/ui-login\r\n| worktree: \"ui-login\" | task_id: 2 |\r\n+------------------+ +------------------------+\r\n |\r\n index.json (worktree registry)\r\n events.jsonl (lifecycle log)\r\n\r\nState machines:\r\n Task: pending -> in_progress -> completed\r\n Worktree: absent -> active -> removed | kept\r\n```\r\n\r\n## 仕組み\r\n\r\n1. **タスクを作成する。** まず目標を永続化する。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\ntasks.create(\"Implement auth refactor\", \"\");\r\n// -> .tasks/task_1.json status=pending worktree=\"\"\r\n```\r\n\r\n2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\nworktrees.create(\"auth-refactor\", 1, \"HEAD\");\r\n// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\r\n// -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\r\n```\r\n\r\n紐付けは両側に状態を書き込む:\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\npublic String bindWorktree(int taskId, String worktree, String owner) {\r\n var task = load(taskId);\r\n task.put(\"worktree\", worktree);\r\n if (owner != null && !owner.isEmpty()) task.put(\"owner\", owner);\r\n if (\"pending\".equals(task.get(\"status\"))) task.put(\"status\", \"in_progress\");\r\n task.put(\"updated_at\", System.currentTimeMillis() / 1000.0);\r\n save(task);\r\n return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n}\r\n```\r\n\r\n3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()\r\nboolean isWindows = System.getProperty(\"os.name\").toLowerCase().contains(\"win\");\r\nProcessBuilder pb = isWindows\r\n ? new ProcessBuilder(\"cmd\", \"/c\", command)\r\n : new ProcessBuilder(\"sh\", \"-c\", command);\r\npb.directory(path.toFile());\r\npb.redirectErrorStream(true);\r\nProcess p = pb.start();\r\nString out = new String(p.getInputStream().readAllBytes()).trim();\r\nboolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);\r\n```\r\n\r\n4. **終了処理。** 2つの選択肢:\r\n - `worktree_keep(name)` -- ディレクトリを保持する。\r\n - `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\npublic String remove(String name, boolean force, boolean completeTask) {\r\n var wt = findWorktree(name);\r\n events.emit(\"worktree.remove.before\", ...);\r\n runGit(\"worktree\", \"remove\", wt.get(\"path\").toString());\r\n if (completeTask && wt.get(\"task_id\") != null) {\r\n int taskId = ((Number) wt.get(\"task_id\")).intValue();\r\n tasks.update(taskId, \"completed\", Line truncated
}
]
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -1,29 +1,29 @@
{
"version": "s01",
"title": "The Agent Loop",
"description": "A minimal agent that uses only bash to accomplish tasks",
"description": "A minimal agent that uses tools to accomplish tasks",
"steps": [
{
"type": "user_message",
"content": "Create a file called hello.py that prints 'Hello, World!'",
"content": "Create a file called Hello.java that prints 'Hello, World!'",
"annotation": "User sends a task to the agent"
},
{
"type": "assistant_text",
"content": "I'll create that file for you using bash.",
"annotation": "The model decides to use its only tool: bash"
"content": "I'll create that file for you.",
"annotation": "The model decides to use write_file tool"
},
{
"type": "tool_call",
"content": "echo 'print(\"Hello, World!\")' > hello.py",
"toolName": "bash",
"annotation": "Tool call: the model generates a bash command"
"content": "Hello.java\n---\npublic class Hello {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}",
"toolName": "write_file",
"annotation": "Tool call: the model creates a Java source file"
},
{
"type": "tool_result",
"content": "",
"toolName": "bash",
"annotation": "Bash returns empty output (success)"
"content": "File written successfully",
"toolName": "write_file",
"annotation": "File created successfully"
},
{
"type": "assistant_text",
@@ -32,20 +32,20 @@
},
{
"type": "tool_call",
"content": "cat hello.py",
"content": "cat Hello.java",
"toolName": "bash",
"annotation": "Another bash call to verify"
},
{
"type": "tool_result",
"content": "print(\"Hello, World!\")",
"content": "public class Hello {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}",
"toolName": "bash",
"annotation": "File contents confirmed"
},
{
"type": "assistant_text",
"content": "Done! I've created hello.py with the print statement. You can run it with `python hello.py`.",
"annotation": "stop_reason != 'tool_use' -> loop breaks, agent responds to user"
"content": "Done! I've created Hello.java with the main method. You can run it with `javac Hello.java && java Hello`.",
"annotation": "No more tool calls -> loop breaks, agent responds to user"
}
]
}
+5 -5
View File
@@ -5,7 +5,7 @@
"steps": [
{
"type": "user_message",
"content": "Add a function called greet(name) to hello.py",
"content": "Add a method called greet(String name) to Hello.java",
"annotation": "User requests a code modification"
},
{
@@ -15,19 +15,19 @@
},
{
"type": "tool_call",
"content": "hello.py",
"content": "Hello.java",
"toolName": "read_file",
"annotation": "Dedicated read tool provides cleaner file access"
},
{
"type": "tool_result",
"content": "print(\"Hello, World!\")",
"content": "public class Hello {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}",
"toolName": "read_file",
"annotation": "File contents returned to the model"
},
{
"type": "tool_call",
"content": "hello.py\n---\ndef greet(name):\n print(f\"Hello, {name}!\")\n\ngreet(\"World\")",
"content": "Hello.java\n---\npublic class Hello {\n public static void greet(String name) {\n System.out.println(\"Hello, \" + name + \"!\");\n }\n\n public static void main(String[] args) {\n greet(\"World\");\n }\n}",
"toolName": "write_file",
"annotation": "write_file replaces the entire file content"
},
@@ -39,7 +39,7 @@
},
{
"type": "assistant_text",
"content": "I've updated hello.py with a greet(name) function that takes a name parameter.",
"content": "I've updated Hello.java with a greet(String name) method that takes a name parameter.",
"annotation": "4 tools beat 1: each tool has a clear, focused purpose"
}
]
+3 -3
View File
@@ -10,7 +10,7 @@
},
{
"type": "tool_call",
"content": "[\n { \"id\": 1, \"task\": \"Create calc.py with argument parsing\", \"status\": \"in_progress\" },\n { \"id\": 2, \"task\": \"Implement 4 operations\", \"status\": \"pending\" },\n { \"id\": 3, \"task\": \"Add error handling\", \"status\": \"pending\" },\n { \"id\": 4, \"task\": \"Test all operations\", \"status\": \"pending\" }\n]",
"content": "[\n { \"id\": 1, \"task\": \"Create Calc.java with argument parsing\", \"status\": \"in_progress\" },\n { \"id\": 2, \"task\": \"Implement 4 operations\", \"status\": \"pending\" },\n { \"id\": 3, \"task\": \"Add error handling\", \"status\": \"pending\" },\n { \"id\": 4, \"task\": \"Test all operations\", \"status\": \"pending\" }\n]",
"toolName": "todo_write",
"annotation": "TodoManager creates a visible plan BEFORE any code is written"
},
@@ -22,7 +22,7 @@
},
{
"type": "tool_call",
"content": "calc.py\n---\nimport sys\n\ndef calculate(op, a, b):\n ops = {'+': a+b, '-': a-b, '*': a*b, '/': a/b}\n return ops.get(op, 'Unknown operator')\n\nif __name__ == '__main__':\n a, op, b = float(sys.argv[1]), sys.argv[2], float(sys.argv[3])\n print(calculate(op, a, b))",
"content": "Calc.java\n---\npublic class Calc {\n public static double calculate(String op, double a, double b) {\n switch (op) {\n case \"+\": return a + b;\n case \"-\": return a - b;\n case \"*\": return a * b;\n case \"/\": return a / b;\n default: throw new IllegalArgumentException(\"Unknown operator: \" + op);\n }\n }\n\n public static void main(String[] args) {\n double a = Double.parseDouble(args[0]);\n String op = args[1];\n double b = Double.parseDouble(args[2]);\n System.out.println(calculate(op, a, b));\n }\n}",
"toolName": "write_file",
"annotation": "Executing task #1 and #2: create file with operations"
},
@@ -34,7 +34,7 @@
},
{
"type": "tool_call",
"content": "python calc.py 10 + 5",
"content": "javac Calc.java && java Calc 10 + 5",
"toolName": "bash",
"annotation": "Executing task #4: testing"
},
+4 -4
View File
@@ -15,7 +15,7 @@
},
{
"type": "tool_call",
"content": "Write unit tests for calc.py covering all 4 operations and edge cases like division by zero",
"content": "Write unit tests for Calc.java covering all 4 operations and edge cases like division by zero",
"toolName": "task",
"annotation": "Task tool spawns a subagent with its own isolated context"
},
@@ -26,13 +26,13 @@
},
{
"type": "tool_result",
"content": "Created test_calc.py with 8 test cases. All passing.",
"content": "Created CalcTest.java with 8 test cases. All passing.",
"toolName": "task",
"annotation": "Subagent completes and returns a summary to parent"
},
{
"type": "tool_call",
"content": "calc.py -> calc/operations.py, calc/cli.py",
"content": "Calc.java -> calc/Operations.java, calc/Cli.java",
"toolName": "write_file",
"annotation": "Parent continues with its own task: refactoring"
},
@@ -44,7 +44,7 @@
},
{
"type": "assistant_text",
"content": "Refactored into calc/operations.py and calc/cli.py. Tests were written by a subagent and are all passing.",
"content": "Refactored into calc/Operations.java and calc/Cli.java. Tests were written by a subagent and are all passing.",
"annotation": "Context isolation prevents confusion between tasks"
}
]
+4 -4
View File
@@ -5,18 +5,18 @@
"steps": [
{
"type": "user_message",
"content": "Analyze all Python files in this project and summarize each one",
"content": "Analyze all Java files in this project and summarize each one",
"annotation": "A task that will generate large amounts of context"
},
{
"type": "tool_call",
"content": "find . -name '*.py' -type f",
"content": "find . -name '*.java' -type f",
"toolName": "bash",
"annotation": "Finding all files to analyze"
},
{
"type": "tool_result",
"content": "./src/main.py\n./src/utils.py\n./src/models.py\n./tests/test_main.py\n... (28 more files)",
"content": "./src/main/java/Main.java\n./src/main/java/Utils.java\n./src/main/java/Models.java\n./src/test/java/MainTest.java\n... (28 more files)",
"toolName": "bash",
"annotation": "32 files found -- reading all of them will generate massive context"
},
@@ -27,7 +27,7 @@
},
{
"type": "tool_call",
"content": "./src/main.py",
"content": "./src/main/java/Main.java",
"toolName": "read_file",
"annotation": "Reading file 1 of 32..."
},
+8 -8
View File
@@ -15,13 +15,13 @@
},
{
"type": "tool_call",
"content": "pytest tests/ -v --timeout=300",
"content": "mvn test -Dsurefire.timeout=300",
"toolName": "background_task",
"annotation": "BackgroundManager spawns a thread for long-running test suite"
},
{
"type": "system_event",
"content": "[BackgroundManager] Thread spawned: task_001\nRunning: pytest tests/ -v --timeout=300\nStatus: in_progress",
"content": "[BackgroundManager] Thread spawned: task_001\nRunning: mvn test -Dsurefire.timeout=300\nStatus: in_progress",
"annotation": "Thread is running independently -- main loop continues"
},
{
@@ -32,24 +32,24 @@
},
{
"type": "tool_call",
"content": "ruff check --fix .",
"content": "mvn spotbugs:check",
"toolName": "bash",
"annotation": "Agent works on linting while tests run in background"
"annotation": "Agent works on static analysis while tests run in background"
},
{
"type": "tool_result",
"content": "Fixed 12 linting errors in 5 files",
"content": "Fixed 12 issues in 5 files",
"toolName": "bash",
"annotation": "Linting fixed concurrently with tests"
"annotation": "Static analysis fixed concurrently with tests"
},
{
"type": "system_event",
"content": "[Notification] Background task_001 completed\nResult: 47 tests passed, 2 failed\nFailed: test_auth.py::test_token_expiry, test_api.py::test_rate_limit",
"content": "[Notification] Background task_001 completed\nResult: 47 tests passed, 2 failed\nFailed: AuthTest::testTokenExpiry, ApiTest::testRateLimit",
"annotation": "Background thread notifies main loop via notification queue"
},
{
"type": "assistant_text",
"content": "Linting is fixed. Tests finished: 47 passed, 2 failed. I'll fix the failing tests now.",
"content": "Static analysis is fixed. Tests finished: 47 passed, 2 failed. I'll fix the failing tests now.",
"annotation": "Non-blocking execution via threads + queue enables parallel work"
}
]
+2 -2
View File
@@ -5,12 +5,12 @@
"steps": [
{
"type": "user_message",
"content": "Build a full-stack app: frontend in React, backend in Python, with tests",
"content": "Build a full-stack app: frontend in React, backend in Java/Spring Boot, with tests",
"annotation": "Large task requiring team collaboration"
},
{
"type": "tool_call",
"content": "spawn_teammate(\n name: \"frontend-dev\",\n role: \"React specialist\"\n)\nspawn_teammate(\n name: \"backend-dev\",\n role: \"Python API specialist\"\n)",
"content": "spawn_teammate(\n name: \"frontend-dev\",\n role: \"React specialist\"\n)\nspawn_teammate(\n name: \"backend-dev\",\n role: \"Java/Spring Boot API specialist\"\n)",
"toolName": "teammate_manager",
"annotation": "Unlike subagents (s04) that die after one task, teammates persist"
},
+23 -2
View File
@@ -30,13 +30,20 @@
"none": "None",
"source_diff": "Source Code Diff",
"empty_hint": "Select two versions above to compare them.",
"architecture": "Architecture"
"architecture": "Architecture",
"select_placeholder": "-- select --"
},
"diff": {
"new_classes": "New Classes",
"new_tools": "New Tools",
"new_functions": "New Functions",
"loc_delta": "LOC Delta"
"loc_delta": "LOC Delta",
"lines": "lines",
"source_diff": "Source Code Diff",
"version_not_found": "Version not found",
"back_to_timeline": "Back to timeline",
"back_to": "Back to",
"first_version_hint": "This is the first version -- there is no previous version to compare against."
},
"sessions": {
"s01": "The Agent Loop",
@@ -72,5 +79,19 @@
"s10": "FSM Team Protocols",
"s11": "Autonomous Agent Cycle",
"s12": "Worktree Task Isolation"
},
"version_meta": {
"s01": { "title_local": "The Agent Loop", "subtitle": "Bash is All You Need", "keyInsight": "The minimal agent kernel is a while loop + one tool", "coreAddition": "Single-tool agent loop" },
"s02": { "title_local": "Tools", "subtitle": "One Handler Per Tool", "keyInsight": "The loop stays the same; new tools register via @Tool and defaultTools()", "coreAddition": "@Tool annotation + defaultTools()" },
"s03": { "title_local": "TodoWrite", "subtitle": "Plan Before You Act", "keyInsight": "An agent without a plan drifts; list the steps first, then execute", "coreAddition": "TodoManager + nag reminder" },
"s04": { "title_local": "Subagents", "subtitle": "Clean Context Per Subtask", "keyInsight": "Subagents use independent messages[], keeping the main conversation clean", "coreAddition": "Subagent spawn with isolated messages[]" },
"s05": { "title_local": "Skills", "subtitle": "Load on Demand", "keyInsight": "Inject knowledge via tool_result when needed, not upfront in the system prompt", "coreAddition": "SkillLoader + two-layer injection" },
"s06": { "title_local": "Compact", "subtitle": "Three-Layer Compression", "keyInsight": "Context will fill up; three-layer compression strategy enables infinite sessions", "coreAddition": "micro-compact + auto-compact + archival" },
"s07": { "title_local": "Tasks", "subtitle": "Task Graph + Dependencies", "keyInsight": "A file-based task graph with ordering, parallelism, and dependencies -- the coordination backbone for multi-agent work", "coreAddition": "TaskManager with file-based state + dependency graph" },
"s08": { "title_local": "Background Tasks", "subtitle": "Background Threads + Notifications", "keyInsight": "Run slow operations in the background; the agent keeps thinking ahead", "coreAddition": "BackgroundManager + notification queue" },
"s09": { "title_local": "Agent Teams", "subtitle": "Teammates + Mailboxes", "keyInsight": "When one agent can't finish, delegate to persistent teammates via async mailboxes", "coreAddition": "TeammateManager + file-based mailbox" },
"s10": { "title_local": "Team Protocols", "subtitle": "Shared Communication Rules", "keyInsight": "One request-response pattern drives all team negotiation", "coreAddition": "request_id correlation for two protocols" },
"s11": { "title_local": "Autonomous Agents", "subtitle": "Scan Board, Claim Tasks", "keyInsight": "Teammates scan the board and claim tasks themselves; no need for the lead to assign each one", "coreAddition": "Task board polling + timeout-based self-governance" },
"s12": { "title_local": "Worktree + Task Isolation", "subtitle": "Isolate by Directory", "keyInsight": "Each works in its own directory; tasks manage goals, worktrees manage directories, bound by ID", "coreAddition": "Composable worktree lifecycle + event stream over a shared task board" }
}
}
+23 -2
View File
@@ -30,13 +30,20 @@
"none": "なし",
"source_diff": "ソースコード差分",
"empty_hint": "上で2つのバージョンを選択して比較してください。",
"architecture": "アーキテクチャ"
"architecture": "アーキテクチャ",
"select_placeholder": "-- 選択してください --"
},
"diff": {
"new_classes": "新規クラス",
"new_tools": "新規ツール",
"new_functions": "新規関数",
"loc_delta": "コード量の差分"
"loc_delta": "コード量の差分",
"lines": "行",
"source_diff": "ソースコード差分",
"version_not_found": "バージョンが見つかりません",
"back_to_timeline": "学習パスに戻る",
"back_to": "に戻る",
"first_version_hint": "これは最初のバージョンです。比較対象の前バージョンがありません。"
},
"sessions": {
"s01": "エージェントループ",
@@ -72,5 +79,19 @@
"s10": "FSM チームプロトコル",
"s11": "自律エージェントサイクル",
"s12": "Worktree タスク分離"
},
"version_meta": {
"s01": { "title_local": "エージェントループ", "subtitle": "ループ + Bash だけで十分", "keyInsight": "最小エージェントカーネルはwhileループ + 1つのツール", "coreAddition": "単一ツールエージェントループ" },
"s02": { "title_local": "ツール", "subtitle": "ツールごとに1つの @Tool", "keyInsight": "ループは変わらない; 新ツールは @Tool と defaultTools() で登録", "coreAddition": "@Tool アノテーション + defaultTools()" },
"s03": { "title_local": "TodoWrite", "subtitle": "行動前に計画", "keyInsight": "計画のないエージェントは迷走する; まずステップを列挙してから実行", "coreAddition": "TodoManager + リマインダー" },
"s04": { "title_local": "サブエージェント", "subtitle": "サブタスクごとにクリーンなコンテキスト", "keyInsight": "サブエージェントは独立したメッセージリストを使用し、メイン会話をクリーンに保つ", "coreAddition": "独立コンテキストのサブエージェント" },
"s05": { "title_local": "スキル", "subtitle": "オンデマンドロード", "keyInsight": "必要な時にツール結果経由で知識を注入、システムプロンプトに事前に詰め込まない", "coreAddition": "SkillLoader + 二層注入" },
"s06": { "title_local": "コンテキスト圧縮", "subtitle": "三層圧縮戦略", "keyInsight": "コンテキストは必ず満杯になる; 三層圧縮戦略で無限セッションを実現", "coreAddition": "マイクロ圧縮 + 自動圧縮 + アーカイブ" },
"s07": { "title_local": "タスクシステム", "subtitle": "タスクグラフ + 依存関係", "keyInsight": "ファイルベースのタスクグラフ、順序付け・並列・依存をサポート -- マルチエージェント連携の基盤", "coreAddition": "ファイル永続化 TaskManager + 依存グラフ" },
"s08": { "title_local": "バックグラウンドタスク", "subtitle": "仮想スレッド + 通知", "keyInsight": "遅い操作はバックグラウンドへ; エージェントは次を考え続ける", "coreAddition": "BackgroundManager + 通知キュー" },
"s09": { "title_local": "エージェントチーム", "subtitle": "チームメイト + メールボックス", "keyInsight": "一人で終わらない時は永続チームメイトに非同期メールボックスで委託", "coreAddition": "TeammateManager + ファイルメールボックス" },
"s10": { "title_local": "チームプロトコル", "subtitle": "統一コミュニケーションルール", "keyInsight": "1つの request-response パターンが全てのチーム交渉を駆動", "coreAddition": "request_id 相関の2つのプロトコル" },
"s11": { "title_local": "自律エージェント", "subtitle": "ボードをスキャンしてタスクを認領", "keyInsight": "チームメイトが自分でボードをスキャンしてタスクを認領; リーダーの個別割り当て不要", "coreAddition": "タスクボードポーリング + タイムアウトベース自治" },
"s12": { "title_local": "Worktree 隔離", "subtitle": "各自のディレクトリで作業", "keyInsight": "各自独立ディレクトリ; タスクが目標を管理、worktreeがディレクトリを管理、IDで紐付け", "coreAddition": "構成可能なworktreeライフサイクル + イベントストリーム" }
}
}
+23 -2
View File
@@ -30,13 +30,20 @@
"none": "无",
"source_diff": "源码差异",
"empty_hint": "请在上方选择两个版本进行对比。",
"architecture": "架构"
"architecture": "架构",
"select_placeholder": "-- 请选择 --"
},
"diff": {
"new_classes": "新增类",
"new_tools": "新增工具",
"new_functions": "新增函数",
"loc_delta": "代码量差异"
"loc_delta": "代码量差异",
"lines": "行",
"source_diff": "源码差异",
"version_not_found": "未找到版本",
"back_to_timeline": "返回学习路径",
"back_to": "返回",
"first_version_hint": "这是第一个版本,没有可对比的前一版本。"
},
"sessions": {
"s01": "Agent 循环",
@@ -72,5 +79,19 @@
"s10": "FSM 团队协议",
"s11": "自主 Agent 循环",
"s12": "Worktree 任务隔离"
},
"version_meta": {
"s01": { "title_local": "Agent 循环", "subtitle": "一个循环 + Bash 就够了", "keyInsight": "最小 Agent 内核就是一个循环加一个工具", "coreAddition": "单工具 Agent 循环" },
"s02": { "title_local": "工具", "subtitle": "每个工具只加一个 @Tool", "keyInsight": "循环不变; 新工具通过 @Tool 和 defaultTools() 注册", "coreAddition": "@Tool 注解 + defaultTools()" },
"s03": { "title_local": "TodoWrite 计划", "subtitle": "先计划再行动", "keyInsight": "没有计划的 Agent 漫无目的; 先列步骤再执行", "coreAddition": "TodoManager + 提醒机制" },
"s04": { "title_local": "子 Agent", "subtitle": "每个子任务独立上下文", "keyInsight": "子 Agent 使用独立消息列表, 保持主对话干净", "coreAddition": "独立消息上下文的子 Agent" },
"s05": { "title_local": "技能", "subtitle": "按需加载", "keyInsight": "通过工具结果按需注入知识, 不预先塞进系统提示", "coreAddition": "SkillLoader + 两层注入" },
"s06": { "title_local": "上下文压缩", "subtitle": "三层压缩策略", "keyInsight": "上下文总会满; 三层压缩策略实现无限会话", "coreAddition": "微压缩 + 自动压缩 + 归档" },
"s07": { "title_local": "任务系统", "subtitle": "任务图 + 依赖关系", "keyInsight": "基于文件的任务图, 支持排序、并行和依赖 -- 多 Agent 协作的协调骨架", "coreAddition": "文件持久化的 TaskManager + 依赖图" },
"s08": { "title_local": "后台任务", "subtitle": "虚拟线程 + 通知", "keyInsight": "慢操作丢后台; Agent 继续思考下一步", "coreAddition": "BackgroundManager + 通知队列" },
"s09": { "title_local": "Agent 团队", "subtitle": "队友 + 邮箱", "keyInsight": "一个人干不完就委托给持久化队友, 通过异步邮箱通信", "coreAddition": "TeammateManager + 文件邮箱" },
"s10": { "title_local": "团队协议", "subtitle": "统一沟通规矩", "keyInsight": "一个 request-response 模式驱动所有团队协商", "coreAddition": "request_id 关联的两个协议" },
"s11": { "title_local": "自治 Agent", "subtitle": "扫看板, 认领任务", "keyInsight": "队友自己扫看板认领任务; 不需要领导逐个分配", "coreAddition": "任务板轮询 + 基于超时的自治" },
"s12": { "title_local": "Worktree 隔离", "subtitle": "各干各的目录", "keyInsight": "各自独立目录; 任务管目标, worktree 管目录, 按 ID 绑定", "coreAddition": "可组合 worktree 生命周期 + 事件流" }
}
}
+14 -1
View File
@@ -15,7 +15,7 @@ export const VERSION_META: Record<string, {
prevVersion: string | null;
}> = {
s01: { title: "The Agent Loop", subtitle: "Bash is All You Need", coreAddition: "Single-tool agent loop", keyInsight: "The minimal agent kernel is a while loop + one tool", layer: "tools", prevVersion: null },
s02: { title: "Tools", subtitle: "One Handler Per Tool", coreAddition: "Tool dispatch map", keyInsight: "The loop stays the same; new tools register into the dispatch map", layer: "tools", prevVersion: "s01" },
s02: { title: "Tools", subtitle: "One Handler Per Tool", coreAddition: "@Tool annotation + defaultTools()", keyInsight: "The loop stays the same; new tools register via @Tool and defaultTools()", layer: "tools", prevVersion: "s01" },
s03: { title: "TodoWrite", subtitle: "Plan Before You Act", coreAddition: "TodoManager + nag reminder", keyInsight: "An agent without a plan drifts; list the steps first, then execute", layer: "planning", prevVersion: "s02" },
s04: { title: "Subagents", subtitle: "Clean Context Per Subtask", coreAddition: "Subagent spawn with isolated messages[]", keyInsight: "Subagents use independent messages[], keeping the main conversation clean", layer: "planning", prevVersion: "s03" },
s05: { title: "Skills", subtitle: "Load on Demand", coreAddition: "SkillLoader + two-layer injection", keyInsight: "Inject knowledge via tool_result when needed, not upfront in the system prompt", layer: "planning", prevVersion: "s04" },
@@ -28,6 +28,19 @@ export const VERSION_META: Record<string, {
s12: { title: "Worktree + Task Isolation", subtitle: "Isolate by Directory", coreAddition: "Composable worktree lifecycle + event stream over a shared task board", keyInsight: "Each works in its own directory; tasks manage goals, worktrees manage directories, bound by ID", layer: "collaboration", prevVersion: "s11" },
};
/** 获取本地化的版本元数据,title 保持英文,其余字段使用翻译 */
export function getLocalizedVersionMeta(versionId: string, tMeta?: (key: string) => string) {
const meta = VERSION_META[versionId];
if (!meta || !tMeta) return meta;
return {
...meta,
titleLocal: tMeta(`${versionId}.title_local`) || meta.title,
subtitle: tMeta(`${versionId}.subtitle`) || meta.subtitle,
keyInsight: tMeta(`${versionId}.keyInsight`) || meta.keyInsight,
coreAddition: tMeta(`${versionId}.coreAddition`) || meta.coreAddition,
};
}
export const LAYERS = [
{ id: "tools" as const, label: "Tools & Execution", color: "#3B82F6", versions: ["s01", "s02"] },
{ id: "planning" as const, label: "Planning & Coordination", color: "#10B981", versions: ["s03", "s04", "s05", "s07"] },
+14 -4
View File
@@ -6,11 +6,21 @@ type Messages = typeof en;
const messagesMap: Record<string, Messages> = { en, zh, ja };
function resolveKey(obj: any, key: string): string | undefined {
const parts = key.split(".");
let result: any = obj;
for (const part of parts) {
result = result?.[part];
if (result === undefined) return undefined;
}
return typeof result === "string" ? result : undefined;
}
export function getTranslations(locale: string, namespace: string) {
const messages = messagesMap[locale] || en;
const ns = (messages as Record<string, Record<string, string>>)[namespace];
const fallbackNs = (en as Record<string, Record<string, string>>)[namespace];
const messages = messagesMap[locale] || zh;
const ns = (messages as Record<string, any>)[namespace];
const fallbackNs = (zh as Record<string, any>)[namespace];
return (key: string): string => {
return ns?.[key] || fallbackNs?.[key] || key;
return resolveKey(ns, key) || resolveKey(fallbackNs, key) || key;
};
}
+10 -4
View File
@@ -9,12 +9,12 @@ type Messages = typeof en;
const messagesMap: Record<string, Messages> = { en, zh, ja };
const I18nContext = createContext<{ locale: string; messages: Messages }>({
locale: "en",
messages: en,
locale: "zh",
messages: zh,
});
export function I18nProvider({ locale, children }: { locale: string; children: ReactNode }) {
const messages = messagesMap[locale] || en;
const messages = messagesMap[locale] || zh;
return (
<I18nContext.Provider value={{ locale, messages }}>
{children}
@@ -27,7 +27,13 @@ export function useTranslations(namespace?: string) {
return (key: string) => {
const ns = namespace ? (messages as any)[namespace] : messages;
if (!ns) return key;
return (ns as any)[key] || key;
const parts = key.split(".");
let result: any = ns;
for (const part of parts) {
result = result?.[part];
if (result === undefined) return key;
}
return typeof result === "string" ? result : key;
};
}