feat: build an AI agent from 0 to 1 -- 11 progressive sessions
- 11 sessions from basic agent loop to autonomous teams - Python MVP implementations for each session - Mental-model-first docs in en/zh/ja - Interactive web platform with step-through visualizations - Incremental architecture: each session adds one mechanism
This commit is contained in:
commit
c6a27ef1d7
156 files changed
+28059
No files matched your search
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ArchDiagram } from "@/components/architecture/arch-diagram";
|
||||
import { WhatsNew } from "@/components/diff/whats-new";
|
||||
import { DesignDecisions } from "@/components/architecture/design-decisions";
|
||||
import { DocRenderer } from "@/components/docs/doc-renderer";
|
||||
import { SourceViewer } from "@/components/code/source-viewer";
|
||||
import { AgentLoopSimulator } from "@/components/simulator/agent-loop-simulator";
|
||||
import { ExecutionFlow } from "@/components/architecture/execution-flow";
|
||||
import { SessionVisualization } from "@/components/visualizations";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
|
||||
interface VersionDetailClientProps {
|
||||
version: string;
|
||||
diff: {
|
||||
from: string;
|
||||
to: string;
|
||||
newClasses: string[];
|
||||
newFunctions: string[];
|
||||
newTools: string[];
|
||||
locDelta: number;
|
||||
} | null;
|
||||
source: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function VersionDetailClient({
|
||||
version,
|
||||
diff,
|
||||
source,
|
||||
filename,
|
||||
}: VersionDetailClientProps) {
|
||||
const t = useTranslations("version");
|
||||
|
||||
const tabs = [
|
||||
{ id: "learn", label: t("tab_learn") },
|
||||
{ id: "simulate", label: t("tab_simulate") },
|
||||
{ id: "code", label: t("tab_code") },
|
||||
{ id: "deep-dive", label: t("tab_deep_dive") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Visualization */}
|
||||
<SessionVisualization version={version} />
|
||||
|
||||
{/* Tabbed content */}
|
||||
<Tabs tabs={tabs} defaultTab="learn">
|
||||
{(activeTab) => (
|
||||
<>
|
||||
{activeTab === "learn" && <DocRenderer version={version} />}
|
||||
{activeTab === "simulate" && (
|
||||
<AgentLoopSimulator version={version} />
|
||||
)}
|
||||
{activeTab === "code" && (
|
||||
<SourceViewer source={source} filename={filename} />
|
||||
)}
|
||||
{activeTab === "deep-dive" && (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="mb-4 text-xl font-semibold">
|
||||
{t("execution_flow")}
|
||||
</h2>
|
||||
<ExecutionFlow version={version} />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="mb-4 text-xl font-semibold">
|
||||
{t("architecture")}
|
||||
</h2>
|
||||
<ArchDiagram version={version} />
|
||||
</section>
|
||||
{diff && <WhatsNew diff={diff} />}
|
||||
<DesignDecisions version={version} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { VERSION_META } from "@/lib/constants";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import { CodeDiff } from "@/components/diff/code-diff";
|
||||
import { ArrowLeft, Plus, Minus, FileCode, Wrench, Box, FunctionSquare } from "lucide-react";
|
||||
import type { AgentVersion, VersionDiff, VersionIndex } from "@/types/agent-data";
|
||||
import versionData from "@/data/generated/versions.json";
|
||||
|
||||
const data = versionData as VersionIndex;
|
||||
|
||||
interface DiffPageContentProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function DiffPageContent({ version }: DiffPageContentProps) {
|
||||
const locale = useLocale();
|
||||
const meta = VERSION_META[version];
|
||||
|
||||
const { currentVersion, prevVersion, diff } = useMemo(() => {
|
||||
const current = data.versions.find((v) => v.id === version);
|
||||
const prevId = meta?.prevVersion;
|
||||
const prev = prevId ? data.versions.find((v) => v.id === prevId) : null;
|
||||
const d = data.diffs.find((d) => d.to === version);
|
||||
return { currentVersion: current, prevVersion: prev, diff: d };
|
||||
}, [version, meta]);
|
||||
|
||||
if (!meta || !currentVersion) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-zinc-500">Version not found.</p>
|
||||
<Link href={`/${locale}/timeline`} className="mt-4 inline-block text-sm text-blue-600 hover:underline">
|
||||
Back to timeline
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!prevVersion || !diff) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Link
|
||||
href={`/${locale}/${version}`}
|
||||
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}
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const prevMeta = VERSION_META[prevVersion.id];
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<Link
|
||||
href={`/${locale}/${version}`}
|
||||
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}
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{prevMeta?.title || prevVersion.id} → {meta.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">
|
||||
{prevVersion.id} ({prevVersion.loc} LOC) → {version} ({currentVersion.loc} LOC)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Structural Diff */}
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<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>
|
||||
</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>
|
||||
</CardTitle>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-blue-600 dark:text-blue-400">{diff.newTools.length}</span>
|
||||
</CardTitle>
|
||||
{diff.newTools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{diff.newTools.map((tool) => (
|
||||
<span key={tool} className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-purple-600 dark:text-purple-400">{diff.newClasses.length}</span>
|
||||
</CardTitle>
|
||||
{diff.newClasses.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{diff.newClasses.map((cls) => (
|
||||
<span key={cls} className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{cls}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-amber-600 dark:text-amber-400">{diff.newFunctions.length}</span>
|
||||
</CardTitle>
|
||||
{diff.newFunctions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{diff.newFunctions.map((fn) => (
|
||||
<span key={fn} className="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{fn}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Version Info Comparison */}
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>{prevVersion.loc} LOC</p>
|
||||
<p>{prevVersion.tools.length} tools: {prevVersion.tools.join(", ")}</p>
|
||||
<LayerBadge layer={prevVersion.layer}>{prevVersion.layer}</LayerBadge>
|
||||
</div>
|
||||
</Card>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>{currentVersion.loc} LOC</p>
|
||||
<p>{currentVersion.tools.length} tools: {currentVersion.tools.join(", ")}</p>
|
||||
<LayerBadge layer={currentVersion.layer}>{currentVersion.layer}</LayerBadge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Code Diff */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold">Source Code Diff</h2>
|
||||
<CodeDiff
|
||||
oldSource={prevVersion.source}
|
||||
newSource={currentVersion.source}
|
||||
oldLabel={`${prevVersion.id} (${prevVersion.filename})`}
|
||||
newLabel={`${version} (${currentVersion.filename})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LEARNING_PATH } from "@/lib/constants";
|
||||
import { DiffPageContent } from "./diff-content";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LEARNING_PATH.map((version) => ({ version }));
|
||||
}
|
||||
|
||||
export default async function DiffPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; version: string }>;
|
||||
}) {
|
||||
const { version } = await params;
|
||||
return <DiffPageContent version={version} />;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { LEARNING_PATH, VERSION_META, LAYERS } from "@/lib/constants";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import versionsData from "@/data/generated/versions.json";
|
||||
import { VersionDetailClient } from "./client";
|
||||
import { getTranslations } from "@/lib/i18n-server";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LEARNING_PATH.map((version) => ({ version }));
|
||||
}
|
||||
|
||||
export default async function VersionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; version: string }>;
|
||||
}) {
|
||||
const { locale, version } = await params;
|
||||
|
||||
const versionData = versionsData.versions.find((v) => v.id === version);
|
||||
const meta = VERSION_META[version];
|
||||
const diff = versionsData.diffs.find((d) => d.to === version) ?? null;
|
||||
|
||||
if (!versionData || !meta) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<h1 className="text-2xl font-bold">Version not found</h1>
|
||||
<p className="mt-2 text-zinc-500">{version}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const t = getTranslations(locale, "version");
|
||||
const tSession = getTranslations(locale, "sessions");
|
||||
const tLayer = getTranslations(locale, "layer_labels");
|
||||
const layer = LAYERS.find((l) => l.id === meta.layer);
|
||||
|
||||
const pathIndex = LEARNING_PATH.indexOf(version as typeof LEARNING_PATH[number]);
|
||||
const prevVersion = pathIndex > 0 ? LEARNING_PATH[pathIndex - 1] : null;
|
||||
const nextVersion =
|
||||
pathIndex < LEARNING_PATH.length - 1
|
||||
? LEARNING_PATH[pathIndex + 1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-10 py-4">
|
||||
{/* Header */}
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-lg bg-zinc-100 px-3 py-1 font-mono text-lg font-bold dark:bg-zinc-800">
|
||||
{version}
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold sm:text-3xl">{tSession(version) || meta.title}</h1>
|
||||
{layer && (
|
||||
<LayerBadge layer={meta.layer}>{tLayer(layer.id)}</LayerBadge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg text-zinc-500 dark:text-zinc-400">
|
||||
{meta.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}
|
||||
</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}
|
||||
</blockquote>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Client-rendered interactive sections */}
|
||||
<VersionDetailClient
|
||||
version={version}
|
||||
diff={diff}
|
||||
source={versionData.source}
|
||||
filename={versionData.filename}
|
||||
/>
|
||||
|
||||
{/* Prev / Next navigation */}
|
||||
<nav className="flex items-center justify-between border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
{prevVersion ? (
|
||||
<Link
|
||||
href={`/${locale}/${prevVersion}`}
|
||||
className="group flex items-center gap-2 text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:hover:text-white"
|
||||
>
|
||||
<span className="transition-transform group-hover:-translate-x-1">
|
||||
←
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-400">{t("prev")}</div>
|
||||
<div className="font-medium">
|
||||
{prevVersion} - {tSession(prevVersion) || VERSION_META[prevVersion]?.title}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{nextVersion ? (
|
||||
<Link
|
||||
href={`/${locale}/${nextVersion}`}
|
||||
className="group flex items-center gap-2 text-right text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:hover:text-white"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-400">{t("next")}</div>
|
||||
<div className="font-medium">
|
||||
{tSession(nextVersion) || VERSION_META[nextVersion]?.title} - {nextVersion}
|
||||
</div>
|
||||
</div>
|
||||
<span className="transition-transform group-hover:translate-x-1">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "@/lib/i18n";
|
||||
import { LEARNING_PATH, VERSION_META } from "@/lib/constants";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import { CodeDiff } from "@/components/diff/code-diff";
|
||||
import { ArchDiagram } from "@/components/architecture/arch-diagram";
|
||||
import { ArrowRight, FileCode, Wrench, Box, FunctionSquare } from "lucide-react";
|
||||
import type { VersionIndex } from "@/types/agent-data";
|
||||
import versionData from "@/data/generated/versions.json";
|
||||
|
||||
const data = versionData as VersionIndex;
|
||||
|
||||
export default function ComparePage() {
|
||||
const t = useTranslations("compare");
|
||||
const locale = useLocale();
|
||||
const [versionA, setVersionA] = useState<string>("");
|
||||
const [versionB, setVersionB] = useState<string>("");
|
||||
|
||||
const infoA = useMemo(() => data.versions.find((v) => v.id === versionA), [versionA]);
|
||||
const infoB = useMemo(() => data.versions.find((v) => v.id === versionB), [versionB]);
|
||||
const metaA = versionA ? VERSION_META[versionA] : null;
|
||||
const metaB = versionB ? VERSION_META[versionB] : null;
|
||||
|
||||
const comparison = useMemo(() => {
|
||||
if (!infoA || !infoB) return null;
|
||||
const toolsA = new Set(infoA.tools);
|
||||
const toolsB = new Set(infoB.tools);
|
||||
const onlyA = infoA.tools.filter((t) => !toolsB.has(t));
|
||||
const onlyB = infoB.tools.filter((t) => !toolsA.has(t));
|
||||
const shared = infoA.tools.filter((t) => toolsB.has(t));
|
||||
|
||||
const classesA = new Set(infoA.classes.map((c) => c.name));
|
||||
const classesB = new Set(infoB.classes.map((c) => c.name));
|
||||
const newClasses = infoB.classes.map((c) => c.name).filter((c) => !classesA.has(c));
|
||||
|
||||
const funcsA = new Set(infoA.functions.map((f) => f.name));
|
||||
const funcsB = new Set(infoB.functions.map((f) => f.name));
|
||||
const newFunctions = infoB.functions.map((f) => f.name).filter((f) => !funcsA.has(f));
|
||||
|
||||
return {
|
||||
locDelta: infoB.loc - infoA.loc,
|
||||
toolsOnlyA: onlyA,
|
||||
toolsOnlyB: onlyB,
|
||||
toolsShared: shared,
|
||||
newClasses,
|
||||
newFunctions,
|
||||
};
|
||||
}, [infoA, infoB]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{t("title")}</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Selectors */}
|
||||
<div className="mb-8 flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("select_a")}
|
||||
</label>
|
||||
<select
|
||||
value={versionA}
|
||||
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>
|
||||
{LEARNING_PATH.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v} - {VERSION_META[v]?.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ArrowRight size={20} className="mt-5 hidden text-zinc-400 sm:block" />
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("select_b")}
|
||||
</label>
|
||||
<select
|
||||
value={versionB}
|
||||
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>
|
||||
{LEARNING_PATH.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v} - {VERSION_META[v]?.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{infoA && infoB && comparison && (
|
||||
<div className="space-y-8">
|
||||
{/* Side-by-side version info */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{metaA?.title || versionA}</CardTitle>
|
||||
<p className="text-sm text-zinc-500">{metaA?.subtitle}</p>
|
||||
</CardHeader>
|
||||
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>{infoA.loc} LOC</p>
|
||||
<p>{infoA.tools.length} tools</p>
|
||||
{metaA && <LayerBadge layer={metaA.layer}>{metaA.layer}</LayerBadge>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{metaB?.title || versionB}</CardTitle>
|
||||
<p className="text-sm text-zinc-500">{metaB?.subtitle}</p>
|
||||
</CardHeader>
|
||||
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>{infoB.loc} LOC</p>
|
||||
<p>{infoB.tools.length} tools</p>
|
||||
{metaB && <LayerBadge layer={metaB.layer}>{metaB.layer}</LayerBadge>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side Architecture Diagrams */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold">{t("architecture")}</h2>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{metaA?.title || versionA}
|
||||
</h3>
|
||||
<ArchDiagram version={versionA} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{metaB?.title || versionB}
|
||||
</h3>
|
||||
<ArchDiagram version={versionB} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structural diff */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<FileCode size={16} />
|
||||
<span className="text-sm">{t("loc_delta")}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className={comparison.locDelta >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
|
||||
{comparison.locDelta >= 0 ? "+" : ""}{comparison.locDelta}
|
||||
</span>
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">{t("lines")}</span>
|
||||
</CardTitle>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<Wrench size={16} />
|
||||
<span className="text-sm">{t("new_tools_in_b")}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-blue-600 dark:text-blue-400">{comparison.toolsOnlyB.length}</span>
|
||||
</CardTitle>
|
||||
{comparison.toolsOnlyB.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{comparison.toolsOnlyB.map((tool) => (
|
||||
<span key={tool} className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<Box size={16} />
|
||||
<span className="text-sm">{t("new_classes_in_b")}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-purple-600 dark:text-purple-400">{comparison.newClasses.length}</span>
|
||||
</CardTitle>
|
||||
{comparison.newClasses.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{comparison.newClasses.map((cls) => (
|
||||
<span key={cls} className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
{cls}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
|
||||
<FunctionSquare size={16} />
|
||||
<span className="text-sm">{t("new_functions_in_b")}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardTitle>
|
||||
<span className="text-amber-600 dark:text-amber-400">{comparison.newFunctions.length}</span>
|
||||
</CardTitle>
|
||||
{comparison.newFunctions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{comparison.newFunctions.map((fn) => (
|
||||
<span key={fn} className="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{fn}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tool comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("tool_comparison")}</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("only_in")} {metaA?.title || versionA}
|
||||
</h4>
|
||||
{comparison.toolsOnlyA.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400">{t("none")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{comparison.toolsOnlyA.map((tool) => (
|
||||
<span key={tool} className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("shared")}
|
||||
</h4>
|
||||
{comparison.toolsShared.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400">{t("none")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{comparison.toolsShared.map((tool) => (
|
||||
<span key={tool} className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-zinc-600 dark:text-zinc-400">
|
||||
{t("only_in")} {metaB?.title || versionB}
|
||||
</h4>
|
||||
{comparison.toolsOnlyB.length === 0 ? (
|
||||
<p className="text-xs text-zinc-400">{t("none")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{comparison.toolsOnlyB.map((tool) => (
|
||||
<span key={tool} className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Code Diff */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold">{t("source_diff")}</h2>
|
||||
<CodeDiff
|
||||
oldSource={infoA.source}
|
||||
newSource={infoB.source}
|
||||
oldLabel={`${infoA.id} (${infoA.filename})`}
|
||||
newLabel={`${infoB.id} (${infoB.filename})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{(!versionA || !versionB) && (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-12 text-center dark:border-zinc-700">
|
||||
<p className="text-zinc-400">{t("empty_hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "@/lib/i18n";
|
||||
import { LAYERS, VERSION_META } from "@/lib/constants";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { VersionIndex } from "@/types/agent-data";
|
||||
import versionData from "@/data/generated/versions.json";
|
||||
|
||||
const data = versionData as VersionIndex;
|
||||
|
||||
const LAYER_BORDER_CLASSES: Record<string, string> = {
|
||||
tools: "border-l-blue-500",
|
||||
planning: "border-l-emerald-500",
|
||||
memory: "border-l-purple-500",
|
||||
concurrency: "border-l-amber-500",
|
||||
collaboration: "border-l-red-500",
|
||||
};
|
||||
|
||||
const LAYER_HEADER_BG: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
export default function LayersPage() {
|
||||
const t = useTranslations("layers");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold">{t("title")}</h1>
|
||||
<p className="mt-2 text-zinc-500 dark:text-zinc-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{LAYERS.map((layer, index) => {
|
||||
const versionInfos = layer.versions.map((vId) => {
|
||||
const info = data.versions.find((v) => v.id === vId);
|
||||
const meta = VERSION_META[vId];
|
||||
return { id: vId, info, meta };
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800",
|
||||
"border-l-4",
|
||||
LAYER_BORDER_CLASSES[layer.id]
|
||||
)}
|
||||
>
|
||||
{/* Layer header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<div className={cn("h-3 w-3 rounded-full", LAYER_HEADER_BG[layer.id])} />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">
|
||||
<span className="text-zinc-400 dark:text-zinc-600">L{index + 1}</span>
|
||||
{" "}
|
||||
{layer.label}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t(layer.id)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version cards within this layer */}
|
||||
<div className="border-t border-zinc-200 bg-zinc-50/50 px-6 py-4 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{versionInfos.map(({ id, info, meta }) => (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/${locale}/${id}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-zinc-400">{id}</span>
|
||||
<LayerBadge layer={layer.id}>{layer.id}</LayerBadge>
|
||||
</div>
|
||||
<h3 className="mt-1 font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{meta?.title || id}
|
||||
</h3>
|
||||
{meta?.subtitle && (
|
||||
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="mt-1 shrink-0 text-zinc-300 transition-colors group-hover:text-zinc-600 dark:text-zinc-600 dark:group-hover:text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span>{info?.loc ?? "?"} LOC</span>
|
||||
<span>{info?.tools.length ?? "?"} tools</span>
|
||||
</div>
|
||||
{meta?.keyInsight && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400 line-clamp-2">
|
||||
{meta.keyInsight}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Composition indicator */}
|
||||
{index < LAYERS.length - 1 && (
|
||||
<div className="flex items-center justify-center py-1 text-zinc-300 dark:text-zinc-700">
|
||||
<svg width="20" height="12" viewBox="0 0 20 12" fill="none" className="text-current">
|
||||
<path d="M10 0 L10 12 M5 7 L10 12 L15 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
|
||||
export default function LearnLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<Sidebar />
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { Timeline } from "@/components/timeline/timeline";
|
||||
|
||||
export default function TimelinePage() {
|
||||
const t = useTranslations("timeline");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{t("title")}</h1>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Timeline />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from "next";
|
||||
import { I18nProvider } from "@/lib/i18n";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import en from "@/i18n/messages/en.json";
|
||||
import zh from "@/i18n/messages/zh.json";
|
||||
import ja from "@/i18n/messages/ja.json";
|
||||
import "../globals.css";
|
||||
|
||||
const locales = ["en", "zh", "ja"];
|
||||
const metaMessages: Record<string, typeof en> = { en, zh, ja };
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const messages = metaMessages[locale] || metaMessages.en;
|
||||
return {
|
||||
title: messages.meta?.title || "Learn Claude Code",
|
||||
description: messages.meta?.description || "Build an AI coding agent from scratch, one concept at a time",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
`}} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-[var(--color-bg)] text-[var(--color-text)] antialiased">
|
||||
<I18nProvider locale={locale}>
|
||||
<Header />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "@/lib/i18n";
|
||||
import { LEARNING_PATH, VERSION_META, LAYERS } from "@/lib/constants";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import versionsData from "@/data/generated/versions.json";
|
||||
import { MessageFlow } from "@/components/architecture/message-flow";
|
||||
|
||||
const LAYER_DOT_COLORS: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
const LAYER_BORDER_COLORS: Record<string, string> = {
|
||||
tools: "border-blue-500/30 hover:border-blue-500/60",
|
||||
planning: "border-emerald-500/30 hover:border-emerald-500/60",
|
||||
memory: "border-purple-500/30 hover:border-purple-500/60",
|
||||
concurrency: "border-amber-500/30 hover:border-amber-500/60",
|
||||
collaboration: "border-red-500/30 hover:border-red-500/60",
|
||||
};
|
||||
|
||||
const LAYER_BAR_COLORS: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
function getVersionData(id: string) {
|
||||
return versionsData.versions.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("home");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-20 pb-16">
|
||||
{/* Hero Section */}
|
||||
<section className="flex flex-col items-center px-2 pt-8 text-center sm:pt-20">
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
|
||||
{t("hero_title")}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base text-[var(--color-text-secondary)] sm:text-xl">
|
||||
{t("hero_subtitle")}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={`/${locale}/timeline`}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg bg-zinc-900 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
{t("start")}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Core Pattern Section */}
|
||||
<section>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">{t("core_pattern")}</h2>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
{t("core_pattern_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-800 px-4 py-2.5">
|
||||
<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>
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
|
||||
<code>
|
||||
<span className="text-purple-400">while</span>
|
||||
<span className="text-zinc-300"> </span>
|
||||
<span className="text-orange-300">True</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-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-500">)</span>
|
||||
{"\n"}
|
||||
<span className="text-purple-400">{" "}if</span>
|
||||
<span className="text-zinc-300"> response.stop_reason != </span>
|
||||
<span className="text-green-400">"tool_use"</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>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Message Flow Visualization */}
|
||||
<section>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">{t("message_flow")}</h2>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
{t("message_flow_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<MessageFlow />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Learning Path Preview */}
|
||||
<section>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">{t("learning_path")}</h2>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
{t("learning_path_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LEARNING_PATH.map((versionId) => {
|
||||
const meta = VERSION_META[versionId];
|
||||
const data = getVersionData(versionId);
|
||||
if (!meta || !data) return null;
|
||||
return (
|
||||
<Link
|
||||
key={versionId}
|
||||
href={`/${locale}/${versionId}`}
|
||||
className="group block"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full border transition-all duration-200",
|
||||
LAYER_BORDER_COLORS[meta.layer]
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<LayerBadge layer={meta.layer}>{versionId}</LayerBadge>
|
||||
<span className="text-xs tabular-nums text-[var(--color-text-secondary)]">
|
||||
{data.loc} {t("loc")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-semibold group-hover:underline">
|
||||
{meta.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-secondary)]">
|
||||
{meta.keyInsight}
|
||||
</p>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Layer Overview */}
|
||||
<section>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-bold sm:text-3xl">{t("layers_title")}</h2>
|
||||
<p className="mt-2 text-[var(--color-text-secondary)]">
|
||||
{t("layers_desc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{LAYERS.map((layer) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="flex items-center gap-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-1.5 self-stretch rounded-full",
|
||||
LAYER_BAR_COLORS[layer.id]
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{layer.label}</h3>
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
{layer.versions.length} {t("versions_in_layer")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{layer.versions.map((vid) => {
|
||||
const meta = VERSION_META[vid];
|
||||
return (
|
||||
<Link key={vid} href={`/${locale}/${vid}`}>
|
||||
<LayerBadge
|
||||
layer={layer.id}
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
>
|
||||
{vid}: {meta?.title}
|
||||
</LayerBadge>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,555 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--color-layer-tools: #3B82F6;
|
||||
--color-layer-planning: #10B981;
|
||||
--color-layer-memory: #8B5CF6;
|
||||
--color-layer-concurrency: #F59E0B;
|
||||
--color-layer-collaboration: #EF4444;
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f4f4f5;
|
||||
--color-text: #09090b;
|
||||
--color-text-secondary: #71717a;
|
||||
--color-border: #e4e4e7;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #09090b;
|
||||
--color-bg-secondary: #18181b;
|
||||
--color-text: #fafafa;
|
||||
--color-text-secondary: #a1a1aa;
|
||||
--color-border: #27272a;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
pre, code {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
PROSE-CUSTOM: Premium documentation rendering
|
||||
===================================================== */
|
||||
|
||||
/* -- Headings -- */
|
||||
|
||||
.prose-custom h1 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: #09090b;
|
||||
}
|
||||
|
||||
.dark .prose-custom h1 {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.prose-custom h2 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: #09090b;
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
.dark .prose-custom h2 {
|
||||
color: #fafafa;
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
|
||||
.prose-custom h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.dark .prose-custom h3 {
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.prose-custom h4 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #27272a;
|
||||
}
|
||||
|
||||
.dark .prose-custom h4 {
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
/* -- Paragraphs -- */
|
||||
|
||||
.prose-custom p {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
.dark .prose-custom p {
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
/* -- Hero callout (first blockquote) -- */
|
||||
|
||||
.prose-custom blockquote.hero-callout {
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: none;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #f0fdf4 100%);
|
||||
padding: 1.25rem 1.5rem 1.25rem 1.75rem;
|
||||
font-style: normal;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prose-custom blockquote.hero-callout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(to bottom, #3b82f6, #10b981);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.prose-custom blockquote.hero-callout p {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
font-weight: 500;
|
||||
color: #1e40af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .prose-custom blockquote.hero-callout {
|
||||
background: linear-gradient(135deg, #172554 0%, #052e16 100%);
|
||||
}
|
||||
|
||||
.dark .prose-custom blockquote.hero-callout p {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* -- Regular blockquotes -- */
|
||||
|
||||
.prose-custom blockquote {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 3px solid #a5b4fc;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
background-color: #eef2ff;
|
||||
padding: 0.75rem 1rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.prose-custom blockquote p {
|
||||
color: #4338ca;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .prose-custom blockquote {
|
||||
border-left-color: #6366f1;
|
||||
background-color: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.dark .prose-custom blockquote p {
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
/* -- Code blocks with language label -- */
|
||||
|
||||
.prose-custom pre {
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #1e293b;
|
||||
background-color: #0f172a;
|
||||
padding: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.prose-custom pre.code-block {
|
||||
padding-top: 2.25rem;
|
||||
}
|
||||
|
||||
.prose-custom pre.code-block::before {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0.75rem;
|
||||
padding: 0.125rem 0.625rem 0.25rem;
|
||||
background: #3b82f6;
|
||||
color: #ffffff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.prose-custom pre.code-block[data-language="sh"]::before {
|
||||
background: #22c55e;
|
||||
content: "terminal";
|
||||
}
|
||||
|
||||
/* -- ASCII diagram containers -- */
|
||||
|
||||
.prose-custom pre.ascii-diagram {
|
||||
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.dark .prose-custom pre.ascii-diagram {
|
||||
background: linear-gradient(135deg, #1e1b4b, #172554);
|
||||
border-color: #312e81;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
/* -- Inline code -- */
|
||||
|
||||
.prose-custom :not(pre) > code {
|
||||
border-radius: 0.375rem;
|
||||
background-color: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.125rem 0.425rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #be185d;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.dark .prose-custom :not(pre) > code {
|
||||
background-color: #27272a;
|
||||
border-color: #3f3f46;
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
/* -- Links -- */
|
||||
|
||||
.prose-custom a {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #93c5fd;
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
.prose-custom a:hover {
|
||||
text-decoration-color: #2563eb;
|
||||
}
|
||||
|
||||
.dark .prose-custom a {
|
||||
color: #60a5fa;
|
||||
text-decoration-color: #1e40af;
|
||||
}
|
||||
|
||||
.dark .prose-custom a:hover {
|
||||
text-decoration-color: #60a5fa;
|
||||
}
|
||||
|
||||
/* -- Lists -- */
|
||||
|
||||
.prose-custom ul {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
.dark .prose-custom ul {
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.prose-custom ul > li {
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prose-custom ul > li::marker {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.prose-custom ol {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
counter-reset: step-counter;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
.dark .prose-custom ol {
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.prose-custom ol > li {
|
||||
counter-increment: step-counter;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 2.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prose-custom ol > li::before {
|
||||
content: counter(step-counter);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
color: #ffffff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Reset nested lists inside ol to normal style */
|
||||
.prose-custom ol > li > ul {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.prose-custom ol > li > ul > li {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose-custom ol > li > ul > li::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -- Tables -- */
|
||||
|
||||
.prose-custom table {
|
||||
width: 100%;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .prose-custom table {
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.prose-custom thead {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose-custom th {
|
||||
padding: 0.625rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
background-color: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .prose-custom th {
|
||||
color: #94a3b8;
|
||||
background-color: #18181b;
|
||||
border-bottom-color: #27272a;
|
||||
}
|
||||
|
||||
.prose-custom td {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.prose-custom td code {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dark .prose-custom td {
|
||||
border-bottom-color: #1e1e22;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.prose-custom tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prose-custom tbody tr:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .prose-custom tbody tr:hover {
|
||||
background-color: #111113;
|
||||
}
|
||||
|
||||
/* -- Horizontal rules -- */
|
||||
|
||||
.prose-custom hr {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #d4d4d8, transparent);
|
||||
}
|
||||
|
||||
.dark .prose-custom hr {
|
||||
background: linear-gradient(to right, transparent, #3f3f46, transparent);
|
||||
}
|
||||
|
||||
/* -- Strong / Em -- */
|
||||
|
||||
.prose-custom strong {
|
||||
font-weight: 700;
|
||||
color: #09090b;
|
||||
}
|
||||
|
||||
.dark .prose-custom strong {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.prose-custom em {
|
||||
font-style: italic;
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.dark .prose-custom em {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
HIGHLIGHT.JS TOKEN THEME (code syntax highlighting)
|
||||
===================================================== */
|
||||
|
||||
.hljs {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-type {
|
||||
color: #c084fc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hljs-literal,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-doctag,
|
||||
.hljs-template-variable,
|
||||
.hljs-variable {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.hljs-number {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hljs-title.function_,
|
||||
.hljs-title.class_ {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.hljs-built_in {
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-attribute {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.hljs-params {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.hljs-meta {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
.hljs-tag {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #fca5a5;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #86efac;
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LAYERS } from "@/lib/constants";
|
||||
import versionsData from "@/data/generated/versions.json";
|
||||
|
||||
const CLASS_DESCRIPTIONS: Record<string, string> = {
|
||||
TodoManager: "Visible task planning with constraints",
|
||||
SkillLoader: "Dynamic knowledge injection from SKILL.md files",
|
||||
ContextManager: "Three-layer context compression pipeline",
|
||||
Task: "File-based persistent task with dependencies",
|
||||
TaskManager: "File-based persistent task CRUD with dependencies",
|
||||
BackgroundTask: "Single background execution unit",
|
||||
BackgroundManager: "Non-blocking thread execution + notification queue",
|
||||
TeammateManager: "Multi-agent team lifecycle and coordination",
|
||||
Teammate: "Individual agent identity and state tracking",
|
||||
SharedBoard: "Cross-agent shared state coordination",
|
||||
};
|
||||
|
||||
interface ArchDiagramProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
function getLayerColor(versionId: string): string {
|
||||
const layer = LAYERS.find((l) => (l.versions as readonly string[]).includes(versionId));
|
||||
return layer?.color ?? "#71717a";
|
||||
}
|
||||
|
||||
function getLayerColorClasses(versionId: string): {
|
||||
border: string;
|
||||
bg: string;
|
||||
} {
|
||||
const v =
|
||||
versionsData.versions.find((v) => v.id === versionId) as { layer?: string } | undefined;
|
||||
const layer = v?.layer;
|
||||
switch (layer) {
|
||||
case "tools":
|
||||
return {
|
||||
border: "border-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
};
|
||||
case "planning":
|
||||
return {
|
||||
border: "border-emerald-500",
|
||||
bg: "bg-emerald-500/10",
|
||||
};
|
||||
case "memory":
|
||||
return {
|
||||
border: "border-purple-500",
|
||||
bg: "bg-purple-500/10",
|
||||
};
|
||||
case "concurrency":
|
||||
return {
|
||||
border: "border-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
};
|
||||
case "collaboration":
|
||||
return {
|
||||
border: "border-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
border: "border-zinc-500",
|
||||
bg: "bg-zinc-500/10",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function collectClassesUpTo(
|
||||
targetId: string
|
||||
): { name: string; introducedIn: string }[] {
|
||||
const { versions, diffs } = versionsData;
|
||||
const order = versions.map((v) => v.id);
|
||||
const targetIdx = order.indexOf(targetId);
|
||||
if (targetIdx < 0) return [];
|
||||
|
||||
const result: { name: string; introducedIn: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i <= targetIdx; i++) {
|
||||
const v = versions[i];
|
||||
if (!v.classes) continue;
|
||||
for (const cls of v.classes) {
|
||||
if (!seen.has(cls.name)) {
|
||||
seen.add(cls.name);
|
||||
result.push({ name: cls.name, introducedIn: v.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getNewClassNames(version: string): Set<string> {
|
||||
const diff = versionsData.diffs.find((d) => d.to === version);
|
||||
if (!diff) {
|
||||
const v = versionsData.versions.find((ver) => ver.id === version);
|
||||
return new Set(v?.classes?.map((c) => c.name) ?? []);
|
||||
}
|
||||
return new Set(diff.newClasses ?? []);
|
||||
}
|
||||
|
||||
export function ArchDiagram({ version }: ArchDiagramProps) {
|
||||
const allClasses = collectClassesUpTo(version);
|
||||
const newClassNames = getNewClassNames(version);
|
||||
const versionData = versionsData.versions.find((v) => v.id === version);
|
||||
const tools = versionData?.tools ?? [];
|
||||
|
||||
const reversed = [...allClasses].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{reversed.map((cls, i) => {
|
||||
const isNew = newClassNames.has(cls.name);
|
||||
const colorClasses = getLayerColorClasses(cls.introducedIn);
|
||||
|
||||
return (
|
||||
<div key={cls.name}>
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<motion.svg
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 24 20"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.08 + 0.05 }}
|
||||
>
|
||||
<motion.line
|
||||
x1={12}
|
||||
y1={0}
|
||||
x2={12}
|
||||
y2={14}
|
||||
stroke="var(--color-text-secondary)"
|
||||
strokeWidth={1.5}
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
||||
/>
|
||||
<motion.polygon
|
||||
points="7,12 12,19 17,12"
|
||||
fill="var(--color-text-secondary)"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.08 + 0.2 }}
|
||||
/>
|
||||
</motion.svg>
|
||||
</div>
|
||||
)}
|
||||
<motion.div
|
||||
key={cls.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08, duration: 0.3 }}
|
||||
className={cn(
|
||||
"rounded-lg border-2 px-4 py-3 transition-colors",
|
||||
isNew
|
||||
? cn(colorClasses.border, colorClasses.bg)
|
||||
: "border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-sm font-semibold",
|
||||
isNew
|
||||
? "text-zinc-900 dark:text-white"
|
||||
: "text-zinc-400 dark:text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{cls.name}
|
||||
</span>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 text-xs",
|
||||
isNew
|
||||
? "text-zinc-600 dark:text-zinc-300"
|
||||
: "text-zinc-400 dark:text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{CLASS_DESCRIPTIONS[cls.name] || ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{cls.introducedIn}
|
||||
</span>
|
||||
{isNew && (
|
||||
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[10px] font-bold uppercase text-white dark:bg-white dark:text-zinc-900">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{allClasses.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-400 dark:border-zinc-600">
|
||||
No classes in this version (functions only)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tools.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: reversed.length * 0.08 + 0.1 }}
|
||||
className="flex flex-wrap gap-1.5 pt-2"
|
||||
>
|
||||
{tools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded-md bg-zinc-100 px-2 py-1 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslations, useLocale } from "@/lib/i18n";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import s01Annotations from "@/data/annotations/s01.json";
|
||||
import s02Annotations from "@/data/annotations/s02.json";
|
||||
import s03Annotations from "@/data/annotations/s03.json";
|
||||
import s04Annotations from "@/data/annotations/s04.json";
|
||||
import s05Annotations from "@/data/annotations/s05.json";
|
||||
import s06Annotations from "@/data/annotations/s06.json";
|
||||
import s07Annotations from "@/data/annotations/s07.json";
|
||||
import s08Annotations from "@/data/annotations/s08.json";
|
||||
import s09Annotations from "@/data/annotations/s09.json";
|
||||
import s10Annotations from "@/data/annotations/s10.json";
|
||||
import s11Annotations from "@/data/annotations/s11.json";
|
||||
|
||||
interface Decision {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
alternatives: string;
|
||||
zh?: { title: string; description: string };
|
||||
ja?: { title: string; description: string };
|
||||
}
|
||||
|
||||
interface AnnotationFile {
|
||||
version: string;
|
||||
decisions: Decision[];
|
||||
}
|
||||
|
||||
const ANNOTATIONS: Record<string, AnnotationFile> = {
|
||||
s01: s01Annotations as AnnotationFile,
|
||||
s02: s02Annotations as AnnotationFile,
|
||||
s03: s03Annotations as AnnotationFile,
|
||||
s04: s04Annotations as AnnotationFile,
|
||||
s05: s05Annotations as AnnotationFile,
|
||||
s06: s06Annotations as AnnotationFile,
|
||||
s07: s07Annotations as AnnotationFile,
|
||||
s08: s08Annotations as AnnotationFile,
|
||||
s09: s09Annotations as AnnotationFile,
|
||||
s10: s10Annotations as AnnotationFile,
|
||||
s11: s11Annotations as AnnotationFile,
|
||||
};
|
||||
|
||||
interface DesignDecisionsProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
function DecisionCard({
|
||||
decision,
|
||||
locale,
|
||||
}: {
|
||||
decision: Decision;
|
||||
locale: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useTranslations("version");
|
||||
|
||||
const localized =
|
||||
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string } | undefined : undefined;
|
||||
|
||||
const title = localized?.title || decision.title;
|
||||
const description = localized?.description || decision.description;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left"
|
||||
>
|
||||
<span className="pr-4 text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{title}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn(
|
||||
"shrink-0 text-zinc-400 transition-transform duration-200",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-zinc-100 px-4 py-3 dark:border-zinc-800">
|
||||
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{decision.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}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignDecisions({ version }: DesignDecisionsProps) {
|
||||
const t = useTranslations("version");
|
||||
const locale = useLocale();
|
||||
|
||||
const annotations = ANNOTATIONS[version];
|
||||
if (!annotations || annotations.decisions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t("design_decisions")}</h2>
|
||||
<div className="space-y-2">
|
||||
{annotations.decisions.map((decision, i) => (
|
||||
<motion.div
|
||||
key={decision.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<DecisionCard decision={decision} locale={locale} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { getFlowForVersion } from "@/data/execution-flows";
|
||||
import type { FlowNode, FlowEdge } from "@/types/agent-data";
|
||||
|
||||
const NODE_WIDTH = 140;
|
||||
const NODE_HEIGHT = 40;
|
||||
const DIAMOND_SIZE = 50;
|
||||
|
||||
const LAYER_COLORS: Record<string, string> = {
|
||||
start: "#3B82F6",
|
||||
process: "#10B981",
|
||||
decision: "#F59E0B",
|
||||
subprocess: "#8B5CF6",
|
||||
end: "#EF4444",
|
||||
};
|
||||
|
||||
function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
|
||||
return { cx: node.x, cy: node.y };
|
||||
}
|
||||
|
||||
function getEdgePath(from: FlowNode, to: FlowNode): string {
|
||||
const { cx: x1, cy: y1 } = getNodeCenter(from);
|
||||
const { cx: x2, cy: y2 } = getNodeCenter(to);
|
||||
|
||||
const halfH = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
const halfHTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
|
||||
if (Math.abs(x1 - x2) < 10) {
|
||||
const startY = y1 + halfH;
|
||||
const endY = y2 - halfHTo;
|
||||
return `M ${x1} ${startY} L ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
const startY = y1 + halfH;
|
||||
const endY = y2 - halfHTo;
|
||||
const midY = (startY + endY) / 2;
|
||||
return `M ${x1} ${startY} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${endY}`;
|
||||
}
|
||||
|
||||
function NodeShape({ node }: { node: FlowNode }) {
|
||||
const color = LAYER_COLORS[node.type];
|
||||
const lines = node.label.split("\n");
|
||||
|
||||
if (node.type === "decision") {
|
||||
const half = DIAMOND_SIZE / 2;
|
||||
return (
|
||||
<g>
|
||||
<polygon
|
||||
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === "start" || node.type === "end") {
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - NODE_WIDTH / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={NODE_HEIGHT / 2}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const isSubprocess = node.type === "subprocess";
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - NODE_WIDTH / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={4}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={isSubprocess ? "6 3" : undefined}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 13}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgePath({
|
||||
edge,
|
||||
nodes,
|
||||
index,
|
||||
}: {
|
||||
edge: FlowEdge;
|
||||
nodes: FlowNode[];
|
||||
index: number;
|
||||
}) {
|
||||
const from = nodes.find((n) => n.id === edge.from);
|
||||
const to = nodes.find((n) => n.id === edge.to);
|
||||
if (!from || !to) return null;
|
||||
|
||||
const d = getEdgePath(from, to);
|
||||
const midX = (from.x + to.x) / 2;
|
||||
const midY = (from.y + to.y) / 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<motion.path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="var(--color-text-secondary)"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.12 }}
|
||||
/>
|
||||
{edge.label && (
|
||||
<motion.text
|
||||
x={midX + 8}
|
||||
y={midY - 4}
|
||||
fontSize={10}
|
||||
fill="var(--color-text-secondary)"
|
||||
fontFamily="monospace"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.12 + 0.3 }}
|
||||
>
|
||||
{edge.label}
|
||||
</motion.text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExecutionFlowProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function ExecutionFlow({ version }: ExecutionFlowProps) {
|
||||
const t = useTranslations("version");
|
||||
const [flow, setFlow] = useState<ReturnType<typeof getFlowForVersion>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFlow(getFlowForVersion(version));
|
||||
}, [version]);
|
||||
|
||||
if (!flow) return null;
|
||||
|
||||
const maxY = Math.max(...flow.nodes.map((n) => n.y)) + 50;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-xl font-semibold">{t("execution_flow")}</h2>
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<svg
|
||||
viewBox={`0 0 600 ${maxY}`}
|
||||
className="mx-auto w-full max-w-[600px]"
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth={8}
|
||||
markerHeight={6}
|
||||
refX={8}
|
||||
refY={3}
|
||||
orient="auto"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 8 3, 0 6"
|
||||
fill="var(--color-text-secondary)"
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{flow.edges.map((edge, i) => (
|
||||
<EdgePath key={`${edge.from}-${edge.to}`} edge={edge} nodes={flow.nodes} index={i} />
|
||||
))}
|
||||
|
||||
{flow.nodes.map((node, i) => (
|
||||
<motion.g
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.3 }}
|
||||
>
|
||||
<NodeShape node={node} />
|
||||
</motion.g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const FLOW_STEPS = [
|
||||
{ role: "user", label: "user", color: "bg-blue-500" },
|
||||
{ role: "assistant", label: "assistant", color: "bg-zinc-600" },
|
||||
{ role: "tool_call", label: "tool_call", color: "bg-amber-500" },
|
||||
{ role: "tool_result", label: "tool_result", color: "bg-emerald-500" },
|
||||
{ role: "assistant", label: "assistant", color: "bg-zinc-600" },
|
||||
{ role: "tool_call", label: "tool_call", color: "bg-amber-500" },
|
||||
{ role: "tool_result", label: "tool_result", color: "bg-emerald-500" },
|
||||
{ role: "assistant", label: "assistant (final)", color: "bg-zinc-600" },
|
||||
];
|
||||
|
||||
export function MessageFlow() {
|
||||
const [count, setCount] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCount((prev) => {
|
||||
if (prev >= FLOW_STEPS.length) {
|
||||
setTimeout(() => setCount(0), 1500);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 800);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-[var(--color-text-secondary)]">
|
||||
messages[]
|
||||
</span>
|
||||
<span className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs tabular-nums dark:bg-zinc-800">
|
||||
len={count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
<AnimatePresence>
|
||||
{FLOW_STEPS.slice(0, count).map((step, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.7, width: 0 }}
|
||||
animate={{ opacity: 1, scale: 1, width: "auto" }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className={`flex shrink-0 items-center rounded-md px-2.5 py-1.5 ${step.color}`}
|
||||
>
|
||||
<span className="whitespace-nowrap font-mono text-[10px] font-medium text-white">
|
||||
{step.label}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{count === 0 && (
|
||||
<div className="flex h-7 items-center text-xs text-[var(--color-text-secondary)]">
|
||||
[]
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface SourceViewerProps {
|
||||
source: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
function highlightLine(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("@")) {
|
||||
return [
|
||||
<span key={0} className="text-amber-400">
|
||||
{line}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
|
||||
return [
|
||||
<span key={0} className="text-emerald-500">
|
||||
{line}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
const keywordSet = new Set([
|
||||
"def", "class", "import", "from", "return", "if", "elif", "else",
|
||||
"while", "for", "in", "not", "and", "or", "is", "None", "True",
|
||||
"False", "try", "except", "raise", "with", "as", "yield", "break",
|
||||
"continue", "pass", "global", "lambda", "async", "await",
|
||||
]);
|
||||
|
||||
const parts = line.split(
|
||||
/(\b(?:def|class|import|from|return|if|elif|else|while|for|in|not|and|or|is|None|True|False|try|except|raise|with|as|yield|break|continue|pass|global|lambda|async|await|self)\b|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|f"(?:[^"\\]|\\.)*"|f'(?:[^'\\]|\\.)*'|#.*$|\b\d+(?:\.\d+)?\b)/
|
||||
);
|
||||
|
||||
return parts.map((part, idx) => {
|
||||
if (!part) return null;
|
||||
if (keywordSet.has(part)) {
|
||||
return <span key={idx} className="text-blue-400 font-medium">{part}</span>;
|
||||
}
|
||||
if (part === "self") {
|
||||
return <span key={idx} className="text-purple-400">{part}</span>;
|
||||
}
|
||||
if (part.startsWith("#")) {
|
||||
return <span key={idx} className="text-zinc-400 italic">{part}</span>;
|
||||
}
|
||||
if (
|
||||
(part.startsWith('"') && part.endsWith('"')) ||
|
||||
(part.startsWith("'") && part.endsWith("'")) ||
|
||||
(part.startsWith('f"') && part.endsWith('"')) ||
|
||||
(part.startsWith("f'") && part.endsWith("'"))
|
||||
) {
|
||||
return <span key={idx} className="text-emerald-500">{part}</span>;
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.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 lines = useMemo(() => source.split("\n"), [source]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-700">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-3 w-3 rounded-full bg-red-400" />
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-400" />
|
||||
<span className="h-3 w-3 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<span className="font-mono text-xs text-zinc-400">{filename}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto bg-zinc-950">
|
||||
<pre className="p-2 text-[10px] leading-4 sm:p-4 sm:text-xs sm:leading-5">
|
||||
<code>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className="mr-2 inline-block w-6 shrink-0 select-none text-right text-zinc-600 sm:mr-4 sm:w-8">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-zinc-200">
|
||||
{highlightLine(line)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { diffLines, Change } from "diff";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeDiffProps {
|
||||
oldSource: string;
|
||||
newSource: string;
|
||||
oldLabel: string;
|
||||
newLabel: string;
|
||||
}
|
||||
|
||||
export function CodeDiff({ oldSource, newSource, oldLabel, newLabel }: CodeDiffProps) {
|
||||
const [viewMode, setViewMode] = useState<"unified" | "split">("unified");
|
||||
|
||||
const changes = useMemo(() => diffLines(oldSource, newSource), [oldSource, newSource]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="font-medium text-zinc-700 dark:text-zinc-300">{oldLabel}</span>
|
||||
{" -> "}
|
||||
<span className="font-medium text-zinc-700 dark:text-zinc-300">{newLabel}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={() => setViewMode("unified")}
|
||||
className={cn(
|
||||
"min-h-[36px] px-3 text-xs font-medium transition-colors",
|
||||
viewMode === "unified"
|
||||
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
||||
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("split")}
|
||||
className={cn(
|
||||
"min-h-[36px] px-3 text-xs font-medium transition-colors sm:inline-flex hidden",
|
||||
viewMode === "split"
|
||||
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
||||
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "unified" ? (
|
||||
<UnifiedView changes={changes} />
|
||||
) : (
|
||||
<SplitView changes={changes} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedView({ changes }: { changes: Change[] }) {
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
|
||||
const rows: { oldNum: number | null; newNum: number | null; type: "add" | "remove" | "context"; text: string }[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const lines = change.value.replace(/\n$/, "").split("\n");
|
||||
for (const line of lines) {
|
||||
if (change.added) {
|
||||
rows.push({ oldNum: null, newNum: newLine++, type: "add", text: line });
|
||||
} else if (change.removed) {
|
||||
rows.push({ oldNum: oldLine++, newNum: null, type: "remove", text: line });
|
||||
} else {
|
||||
rows.push({ oldNum: oldLine++, newNum: newLine++, type: "context", text: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table className="w-full border-collapse font-mono text-xs leading-5">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={cn(
|
||||
row.type === "add" && "bg-green-50 dark:bg-green-950/30",
|
||||
row.type === "remove" && "bg-red-50 dark:bg-red-950/30"
|
||||
)}
|
||||
>
|
||||
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
|
||||
{row.oldNum ?? ""}
|
||||
</td>
|
||||
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
|
||||
{row.newNum ?? ""}
|
||||
</td>
|
||||
<td className="w-4 select-none px-1 text-center">
|
||||
{row.type === "add" && <span className="text-green-600 dark:text-green-400">+</span>}
|
||||
{row.type === "remove" && <span className="text-red-600 dark:text-red-400">-</span>}
|
||||
</td>
|
||||
<td className="whitespace-pre px-2">
|
||||
<span
|
||||
className={cn(
|
||||
row.type === "add" && "text-green-800 dark:text-green-300",
|
||||
row.type === "remove" && "text-red-800 dark:text-red-300",
|
||||
row.type === "context" && "text-zinc-700 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{row.text}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitView({ changes }: { changes: Change[] }) {
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
|
||||
type SplitRow = {
|
||||
left: { num: number | null; text: string; type: "remove" | "context" | "empty" };
|
||||
right: { num: number | null; text: string; type: "add" | "context" | "empty" };
|
||||
};
|
||||
|
||||
const rows: SplitRow[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const lines = change.value.replace(/\n$/, "").split("\n");
|
||||
if (change.removed) {
|
||||
for (const line of lines) {
|
||||
rows.push({
|
||||
left: { num: oldLine++, text: line, type: "remove" },
|
||||
right: { num: null, text: "", type: "empty" },
|
||||
});
|
||||
}
|
||||
} else if (change.added) {
|
||||
let filled = 0;
|
||||
for (const line of lines) {
|
||||
// Try to fill in empty right-side slots from preceding removes
|
||||
const lastUnfilled = rows.length - lines.length + filled;
|
||||
if (
|
||||
lastUnfilled >= 0 &&
|
||||
lastUnfilled < rows.length &&
|
||||
rows[lastUnfilled].right.type === "empty" &&
|
||||
rows[lastUnfilled].left.type === "remove"
|
||||
) {
|
||||
rows[lastUnfilled].right = { num: newLine++, text: line, type: "add" };
|
||||
} else {
|
||||
rows.push({
|
||||
left: { num: null, text: "", type: "empty" },
|
||||
right: { num: newLine++, text: line, type: "add" },
|
||||
});
|
||||
}
|
||||
filled++;
|
||||
}
|
||||
} else {
|
||||
for (const line of lines) {
|
||||
rows.push({
|
||||
left: { num: oldLine++, text: line, type: "context" },
|
||||
right: { num: newLine++, text: line, type: "context" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cellClass = (type: string) =>
|
||||
cn(
|
||||
"whitespace-pre px-2",
|
||||
type === "add" && "bg-green-50 text-green-800 dark:bg-green-950/30 dark:text-green-300",
|
||||
type === "remove" && "bg-red-50 text-red-800 dark:bg-red-950/30 dark:text-red-300",
|
||||
type === "context" && "text-zinc-700 dark:text-zinc-300",
|
||||
type === "empty" && "bg-zinc-50 dark:bg-zinc-900"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table className="w-full border-collapse font-mono text-xs leading-5">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
|
||||
{row.left.num ?? ""}
|
||||
</td>
|
||||
<td className={cn("w-1/2 border-r border-zinc-200 dark:border-zinc-700", cellClass(row.left.type))}>
|
||||
{row.left.text}
|
||||
</td>
|
||||
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
|
||||
{row.right.num ?? ""}
|
||||
</td>
|
||||
<td className={cn("w-1/2", cellClass(row.right.type))}>
|
||||
{row.right.text}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface WhatsNewProps {
|
||||
diff: {
|
||||
from: string;
|
||||
to: string;
|
||||
newClasses: string[];
|
||||
newFunctions: string[];
|
||||
newTools: string[];
|
||||
locDelta: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function WhatsNew({ diff }: WhatsNewProps) {
|
||||
const t = useTranslations("version");
|
||||
const td = useTranslations("diff");
|
||||
|
||||
if (!diff) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasContent =
|
||||
diff.newClasses.length > 0 ||
|
||||
diff.newTools.length > 0 ||
|
||||
diff.newFunctions.length > 0 ||
|
||||
diff.locDelta !== 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t("whats_new")}</h2>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{diff.newClasses.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{td("new_classes")}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{diff.newClasses.map((cls) => (
|
||||
<div
|
||||
key={cls}
|
||||
className="rounded-md bg-emerald-50 px-3 py-1.5 font-mono text-sm font-medium text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300"
|
||||
>
|
||||
{cls}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{diff.newTools.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{td("new_tools")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{diff.newTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded-full bg-blue-50 px-3 py-1 font-mono text-xs font-medium text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{diff.newFunctions.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{td("new_functions")}
|
||||
</h3>
|
||||
<ul className="space-y-1 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{diff.newFunctions.map((fn) => (
|
||||
<li key={fn} className="font-mono">
|
||||
<span className="text-zinc-400 dark:text-zinc-500">
|
||||
def{" "}
|
||||
</span>
|
||||
{fn}()
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{diff.locDelta !== 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<Card className="flex h-full items-center">
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{td("loc_delta")}
|
||||
</h3>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
+{diff.locDelta} lines
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import docsData from "@/data/generated/docs.json";
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
|
||||
interface DocRendererProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
const result = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeHighlight, { detect: false, ignoreMissing: true })
|
||||
.use(rehypeStringify)
|
||||
.processSync(md);
|
||||
return String(result);
|
||||
}
|
||||
|
||||
function postProcessHtml(html: string): string {
|
||||
// Add language labels to highlighted code blocks
|
||||
html = html.replace(
|
||||
/<pre><code class="hljs language-(\w+)">/g,
|
||||
'<pre class="code-block" data-language="$1"><code class="hljs language-$1">'
|
||||
);
|
||||
|
||||
// Wrap plain pre>code (ASCII art / diagrams) in diagram container
|
||||
html = html.replace(
|
||||
/<pre><code(?! class="hljs)([^>]*)>/g,
|
||||
'<pre class="ascii-diagram"><code$1>'
|
||||
);
|
||||
|
||||
// Mark the first blockquote as hero callout
|
||||
html = html.replace(
|
||||
/<blockquote>/,
|
||||
'<blockquote class="hero-callout">'
|
||||
);
|
||||
|
||||
// Remove the h1 (it's redundant with the page header)
|
||||
html = html.replace(/<h1>.*?<\/h1>\n?/, "");
|
||||
|
||||
// Fix ordered list counter for interrupted lists (ol start="N")
|
||||
html = html.replace(
|
||||
/<ol start="(\d+)">/g,
|
||||
(_, start) => `<ol style="counter-reset:step-counter ${parseInt(start) - 1}">`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function DocRenderer({ version }: DocRendererProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
const doc = useMemo(() => {
|
||||
const match = docsData.find(
|
||||
(d: { version: string; locale: string }) =>
|
||||
d.version === version && d.locale === locale
|
||||
);
|
||||
if (match) return match;
|
||||
return docsData.find(
|
||||
(d: { version: string; locale: string }) =>
|
||||
d.version === version && d.locale === "en"
|
||||
);
|
||||
}, [version, locale]);
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
const html = useMemo(() => {
|
||||
const raw = renderMarkdown(doc.content);
|
||||
return postProcessHtml(raw);
|
||||
}, [doc.content]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div
|
||||
className="prose-custom"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations, useLocale } from "@/lib/i18n";
|
||||
import { Github, Menu, X, Sun, Moon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: "timeline", href: "/timeline" },
|
||||
{ key: "compare", href: "/compare" },
|
||||
{ key: "layers", href: "/layers" },
|
||||
] as const;
|
||||
|
||||
const LOCALES = [
|
||||
{ code: "en", label: "EN" },
|
||||
{ code: "zh", label: "中文" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const t = useTranslations("nav");
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [dark, setDark] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored) return stored === "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function toggleDark() {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle("dark", next);
|
||||
localStorage.setItem("theme", next ? "dark" : "light");
|
||||
}
|
||||
|
||||
function switchLocale(newLocale: string) {
|
||||
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
|
||||
window.location.href = newPath;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[var(--color-border)] bg-[var(--color-bg)]/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href={`/${locale}`} className="text-lg font-bold">
|
||||
Learn Claude Code
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={`/${locale}${item.href}`}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-zinc-900 dark:hover:text-white",
|
||||
pathname.includes(item.href)
|
||||
? "text-zinc-900 dark:text-white"
|
||||
: "text-zinc-500 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Locale switcher */}
|
||||
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] p-0.5">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => switchLocale(l.code)}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
|
||||
locale === l.code
|
||||
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
||||
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="rounded-md p-1.5 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/shareAI-lab/learn-claude-code"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center md:hidden"
|
||||
>
|
||||
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileOpen && (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-bg)] p-4 md:hidden">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={`/${locale}${item.href}`}
|
||||
className="flex min-h-[44px] items-center text-sm"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-3 flex items-center justify-between border-t border-[var(--color-border)] pt-3">
|
||||
<div className="flex gap-2">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => switchLocale(l.code)}
|
||||
className={cn(
|
||||
"min-h-[44px] min-w-[44px] rounded-md px-3 text-xs font-medium",
|
||||
locale === l.code
|
||||
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
||||
: "border border-[var(--color-border)]"
|
||||
)}
|
||||
>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/shareAI-lab/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"
|
||||
>
|
||||
<Github size={18} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LAYERS, VERSION_META } from "@/lib/constants";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LAYER_DOT_BG: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const locale = pathname.split("/")[1] || "en";
|
||||
const t = useTranslations("sessions");
|
||||
const tLayer = useTranslations("layer_labels");
|
||||
|
||||
return (
|
||||
<nav className="hidden w-56 shrink-0 md:block">
|
||||
<div className="sticky top-[calc(3.5rem+2rem)] space-y-5">
|
||||
{LAYERS.map((layer) => (
|
||||
<div key={layer.id}>
|
||||
<div className="flex items-center gap-1.5 pb-1.5">
|
||||
<span className={cn("h-2 w-2 rounded-full", LAYER_DOT_BG[layer.id])} />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
|
||||
{tLayer(layer.id)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{layer.versions.map((vId) => {
|
||||
const meta = VERSION_META[vId];
|
||||
const href = `/${locale}/${vId}`;
|
||||
const isActive =
|
||||
pathname === href ||
|
||||
pathname === `${href}/` ||
|
||||
pathname.startsWith(`${href}/diff`);
|
||||
|
||||
return (
|
||||
<li key={vId}>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"block rounded-md px-2.5 py-1.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-zinc-100 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-white"
|
||||
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800/50 dark:hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-xs">{vId}</span>
|
||||
<span className="ml-1.5">{t(vId) || meta?.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { useSimulator } from "@/hooks/useSimulator";
|
||||
import { SimulatorControls } from "./simulator-controls";
|
||||
import { SimulatorMessage } from "./simulator-message";
|
||||
import type { Scenario } from "@/types/agent-data";
|
||||
|
||||
const scenarioModules: Record<string, () => Promise<{ default: Scenario }>> = {
|
||||
s01: () => import("@/data/scenarios/s01.json") as Promise<{ default: Scenario }>,
|
||||
s02: () => import("@/data/scenarios/s02.json") as Promise<{ default: Scenario }>,
|
||||
s03: () => import("@/data/scenarios/s03.json") as Promise<{ default: Scenario }>,
|
||||
s04: () => import("@/data/scenarios/s04.json") as Promise<{ default: Scenario }>,
|
||||
s05: () => import("@/data/scenarios/s05.json") as Promise<{ default: Scenario }>,
|
||||
s06: () => import("@/data/scenarios/s06.json") as Promise<{ default: Scenario }>,
|
||||
s07: () => import("@/data/scenarios/s07.json") as Promise<{ default: Scenario }>,
|
||||
s08: () => import("@/data/scenarios/s08.json") as Promise<{ default: Scenario }>,
|
||||
s09: () => import("@/data/scenarios/s09.json") as Promise<{ default: Scenario }>,
|
||||
s10: () => import("@/data/scenarios/s10.json") as Promise<{ default: Scenario }>,
|
||||
s11: () => import("@/data/scenarios/s11.json") as Promise<{ default: Scenario }>,
|
||||
};
|
||||
|
||||
interface AgentLoopSimulatorProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
|
||||
const t = useTranslations("version");
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loader = scenarioModules[version];
|
||||
if (loader) {
|
||||
loader().then((mod) => setScenario(mod.default));
|
||||
}
|
||||
}, [version]);
|
||||
|
||||
const sim = useSimulator(scenario?.steps ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [sim.visibleSteps.length]);
|
||||
|
||||
if (!scenario) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xl font-semibold">{t("simulator")}</h2>
|
||||
<p className="mb-4 text-sm text-[var(--color-text-secondary)]">
|
||||
{scenario.description}
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--color-border)]">
|
||||
<div className="border-b border-[var(--color-border)] bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
|
||||
<SimulatorControls
|
||||
isPlaying={sim.isPlaying}
|
||||
isComplete={sim.isComplete}
|
||||
currentIndex={sim.currentIndex}
|
||||
totalSteps={sim.totalSteps}
|
||||
speed={sim.speed}
|
||||
onPlay={sim.play}
|
||||
onPause={sim.pause}
|
||||
onStep={sim.stepForward}
|
||||
onReset={sim.reset}
|
||||
onSpeedChange={sim.setSpeed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex max-h-[500px] min-h-[200px] flex-col gap-3 overflow-y-auto p-4"
|
||||
>
|
||||
{sim.visibleSteps.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-secondary)]">
|
||||
Press Play or Step to begin
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sim.visibleSteps.map((step, i) => (
|
||||
<SimulatorMessage key={i} step={step} index={i} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
import { Play, Pause, SkipForward, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SimulatorControlsProps {
|
||||
isPlaying: boolean;
|
||||
isComplete: boolean;
|
||||
currentIndex: number;
|
||||
totalSteps: number;
|
||||
speed: number;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStep: () => void;
|
||||
onReset: () => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
}
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4];
|
||||
|
||||
export function SimulatorControls({
|
||||
isPlaying,
|
||||
isComplete,
|
||||
currentIndex,
|
||||
totalSteps,
|
||||
speed,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStep,
|
||||
onReset,
|
||||
onSpeedChange,
|
||||
}: SimulatorControlsProps) {
|
||||
const t = useTranslations("sim");
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPlaying ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
title={t("pause")}
|
||||
>
|
||||
<Pause size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={isComplete}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
title={t("play")}
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onStep}
|
||||
disabled={isComplete}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] transition-colors hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-zinc-800"
|
||||
title={t("step")}
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
title={t("reset")}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
{t("speed")}:
|
||||
</span>
|
||||
{SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onSpeedChange(s)}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||
speed === s
|
||||
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text)]"
|
||||
)}
|
||||
>
|
||||
{s}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="ml-auto text-xs tabular-nums text-[var(--color-text-secondary)]">
|
||||
{Math.max(0, currentIndex + 1)} {t("step_of")} {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SimStep } from "@/types/agent-data";
|
||||
import { User, Bot, Terminal, ArrowRight, AlertCircle } from "lucide-react";
|
||||
|
||||
interface SimulatorMessageProps {
|
||||
step: SimStep;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<
|
||||
string,
|
||||
{ icon: typeof User; label: string; bgClass: string; borderClass: string }
|
||||
> = {
|
||||
user_message: {
|
||||
icon: User,
|
||||
label: "User",
|
||||
bgClass: "bg-blue-50 dark:bg-blue-950/30",
|
||||
borderClass: "border-blue-200 dark:border-blue-800",
|
||||
},
|
||||
assistant_text: {
|
||||
icon: Bot,
|
||||
label: "Assistant",
|
||||
bgClass: "bg-zinc-50 dark:bg-zinc-900",
|
||||
borderClass: "border-zinc-200 dark:border-zinc-700",
|
||||
},
|
||||
tool_call: {
|
||||
icon: Terminal,
|
||||
label: "Tool Call",
|
||||
bgClass: "bg-amber-50 dark:bg-amber-950/30",
|
||||
borderClass: "border-amber-200 dark:border-amber-800",
|
||||
},
|
||||
tool_result: {
|
||||
icon: ArrowRight,
|
||||
label: "Tool Result",
|
||||
bgClass: "bg-emerald-50 dark:bg-emerald-950/30",
|
||||
borderClass: "border-emerald-200 dark:border-emerald-800",
|
||||
},
|
||||
system_event: {
|
||||
icon: AlertCircle,
|
||||
label: "System",
|
||||
bgClass: "bg-purple-50 dark:bg-purple-950/30",
|
||||
borderClass: "border-purple-200 dark:border-purple-800",
|
||||
},
|
||||
};
|
||||
|
||||
export function SimulatorMessage({ step, index }: SimulatorMessageProps) {
|
||||
const config = TYPE_CONFIG[step.type] || TYPE_CONFIG.assistant_text;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className={cn(
|
||||
"rounded-lg border p-3",
|
||||
config.bgClass,
|
||||
config.borderClass
|
||||
)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Icon size={14} className="shrink-0 text-[var(--color-text-secondary)]" />
|
||||
<span className="text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{config.label}
|
||||
{step.toolName && (
|
||||
<span className="ml-1.5 font-mono text-[var(--color-text)]">
|
||||
{step.toolName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{step.type === "tool_call" || step.type === "tool_result" ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-zinc-900 p-2.5 font-mono text-xs leading-relaxed text-zinc-100 dark:bg-zinc-950">
|
||||
{step.content || "(empty)"}
|
||||
</pre>
|
||||
) : step.type === "system_event" ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-purple-900/80 p-2.5 font-mono text-xs leading-relaxed text-purple-100 dark:bg-purple-950">
|
||||
{step.content}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed">{step.content}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs italic text-[var(--color-text-secondary)]">
|
||||
{step.annotation}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations, useLocale } from "@/lib/i18n";
|
||||
import { LEARNING_PATH, VERSION_META, LAYERS } from "@/lib/constants";
|
||||
import { LayerBadge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import versionsData from "@/data/generated/versions.json";
|
||||
|
||||
const LAYER_DOT_BG: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
const LAYER_LINE_BG: Record<string, string> = {
|
||||
tools: "bg-blue-500/30",
|
||||
planning: "bg-emerald-500/30",
|
||||
memory: "bg-purple-500/30",
|
||||
concurrency: "bg-amber-500/30",
|
||||
collaboration: "bg-red-500/30",
|
||||
};
|
||||
|
||||
const LAYER_BAR_BG: Record<string, string> = {
|
||||
tools: "bg-blue-500",
|
||||
planning: "bg-emerald-500",
|
||||
memory: "bg-purple-500",
|
||||
concurrency: "bg-amber-500",
|
||||
collaboration: "bg-red-500",
|
||||
};
|
||||
|
||||
function getVersionData(id: string) {
|
||||
return versionsData.versions.find((v) => v.id === id);
|
||||
}
|
||||
|
||||
const MAX_LOC = Math.max(
|
||||
...versionsData.versions
|
||||
.filter((v) => LEARNING_PATH.includes(v.id as (typeof LEARNING_PATH)[number]))
|
||||
.map((v) => v.loc)
|
||||
);
|
||||
|
||||
export function Timeline() {
|
||||
const t = useTranslations("timeline");
|
||||
const tv = useTranslations("version");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
{/* Layer Legend */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
{t("layer_legend")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LAYERS.map((layer) => (
|
||||
<div key={layer.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn("h-3 w-3 rounded-full", LAYER_DOT_BG[layer.id])}
|
||||
/>
|
||||
<span className="text-xs font-medium">{layer.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Timeline */}
|
||||
<div className="relative">
|
||||
{LEARNING_PATH.map((versionId, index) => {
|
||||
const meta = VERSION_META[versionId];
|
||||
const data = getVersionData(versionId);
|
||||
if (!meta || !data) return null;
|
||||
|
||||
const isLast = index === LEARNING_PATH.length - 1;
|
||||
const locPercent = Math.round((data.loc / MAX_LOC) * 100);
|
||||
|
||||
return (
|
||||
<div key={versionId} className="relative flex gap-4 pb-8 sm:gap-6">
|
||||
{/* Timeline line + dot */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-4 ring-[var(--color-bg)] sm:h-10 sm:w-10",
|
||||
LAYER_DOT_BG[meta.layer]
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-white sm:text-xs">
|
||||
{versionId.replace("s", "").replace("_mini", "m")}
|
||||
</span>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-0.5 flex-1",
|
||||
LAYER_LINE_BG[
|
||||
VERSION_META[LEARNING_PATH[index + 1]]?.layer || meta.layer
|
||||
]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content card */}
|
||||
<div className="flex-1 pb-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4 transition-colors hover:border-[var(--color-text-secondary)]/30 sm:p-5"
|
||||
>
|
||||
<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}
|
||||
</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}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-[var(--color-text-secondary)]">
|
||||
<span className="tabular-nums">
|
||||
{data.loc} {tv("loc")}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{data.tools.length} {tv("tools")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* LOC bar */}
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
LAYER_BAR_BG[meta.layer]
|
||||
)}
|
||||
style={{ width: `${locPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key insight */}
|
||||
{meta.keyInsight && (
|
||||
<p className="mt-3 text-sm italic text-[var(--color-text-secondary)]">
|
||||
“{meta.keyInsight}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
href={`/${locale}/${versionId}`}
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-zinc-900 hover:underline dark:text-zinc-100"
|
||||
>
|
||||
{t("learn_more")}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* LOC Growth Chart */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">{t("loc_growth")}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{LEARNING_PATH.map((versionId) => {
|
||||
const meta = VERSION_META[versionId];
|
||||
const data = getVersionData(versionId);
|
||||
if (!meta || !data) return null;
|
||||
|
||||
const widthPercent = Math.max(
|
||||
2,
|
||||
Math.round((data.loc / MAX_LOC) * 100)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={versionId} className="flex items-center gap-3">
|
||||
<span className="w-8 shrink-0 text-right text-xs font-medium tabular-nums">
|
||||
{versionId}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-full overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: `${widthPercent}%` }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.05 * LEARNING_PATH.indexOf(versionId) }}
|
||||
className={cn(
|
||||
"flex h-full items-center rounded px-2",
|
||||
LAYER_BAR_BG[meta.layer]
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-medium text-white">
|
||||
{data.loc}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LAYER_COLORS = {
|
||||
tools:
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
planning:
|
||||
"bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
memory:
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
|
||||
concurrency:
|
||||
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
collaboration:
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
} as const;
|
||||
|
||||
interface BadgeProps {
|
||||
layer: keyof typeof LAYER_COLORS;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LayerBadge({ layer, children, className }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
LAYER_COLORS[layer],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ className, children, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children, ...props }: CardProps) {
|
||||
return (
|
||||
<div className={cn("mb-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3 className={cn("text-lg font-semibold", className)} {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TabsProps {
|
||||
tabs: { id: string; label: string }[];
|
||||
defaultTab?: string;
|
||||
children: (activeTab: string) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, defaultTab, children, className }: TabsProps) {
|
||||
const [active, setActive] = useState(defaultTab || tabs[0]?.id || "");
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex border-b border-zinc-200 dark:border-zinc-700">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActive(tab.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors",
|
||||
active === tab.id
|
||||
? "border-b-2 border-zinc-900 text-zinc-900 dark:border-white dark:text-white"
|
||||
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">{children(active)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useTranslations } from "@/lib/i18n";
|
||||
|
||||
const visualizations: Record<
|
||||
string,
|
||||
React.LazyExoticComponent<React.ComponentType<{ title?: string }>>
|
||||
> = {
|
||||
s01: lazy(() => import("./s01-agent-loop")),
|
||||
s02: lazy(() => import("./s02-tool-dispatch")),
|
||||
s03: lazy(() => import("./s03-todo-write")),
|
||||
s04: lazy(() => import("./s04-subagent")),
|
||||
s05: lazy(() => import("./s05-skill-loading")),
|
||||
s06: lazy(() => import("./s06-context-compact")),
|
||||
s07: lazy(() => import("./s07-task-system")),
|
||||
s08: lazy(() => import("./s08-background-tasks")),
|
||||
s09: lazy(() => import("./s09-agent-teams")),
|
||||
s10: lazy(() => import("./s10-team-protocols")),
|
||||
s11: lazy(() => import("./s11-autonomous-agents")),
|
||||
};
|
||||
|
||||
export function SessionVisualization({ version }: { version: string }) {
|
||||
const t = useTranslations("viz");
|
||||
const Component = visualizations[version];
|
||||
if (!Component) return null;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-[500px] animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800" />
|
||||
}
|
||||
>
|
||||
<div className="min-h-[500px]">
|
||||
<Component title={t(version)} />
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
// -- Flowchart node definitions --
|
||||
|
||||
interface FlowNode {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
type: "rect" | "diamond";
|
||||
}
|
||||
|
||||
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: "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" },
|
||||
];
|
||||
|
||||
// Edges between nodes (SVG path data computed inline)
|
||||
interface FlowEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const EDGES: FlowEdge[] = [
|
||||
{ from: "start", to: "api_call" },
|
||||
{ from: "api_call", to: "check" },
|
||||
{ from: "check", to: "execute", label: "tool_use" },
|
||||
{ from: "execute", to: "append" },
|
||||
{ from: "append", to: "api_call" },
|
||||
{ from: "check", to: "end", label: "end_turn" },
|
||||
];
|
||||
|
||||
// Which nodes light up at each step
|
||||
const ACTIVE_NODES_PER_STEP: string[][] = [
|
||||
[],
|
||||
["start"],
|
||||
["api_call"],
|
||||
["check", "execute"],
|
||||
["execute", "append"],
|
||||
["api_call", "check", "execute", "append"],
|
||||
["check", "end"],
|
||||
];
|
||||
|
||||
// Which edges highlight at each step
|
||||
const ACTIVE_EDGES_PER_STEP: string[][] = [
|
||||
[],
|
||||
[],
|
||||
["start->api_call"],
|
||||
["api_call->check", "check->execute"],
|
||||
["execute->append"],
|
||||
["append->api_call", "api_call->check", "check->execute", "execute->append"],
|
||||
["api_call->check", "check->end"],
|
||||
];
|
||||
|
||||
// -- Message blocks --
|
||||
|
||||
interface MessageBlock {
|
||||
role: string;
|
||||
detail: string;
|
||||
colorClass: string;
|
||||
}
|
||||
|
||||
const MESSAGES_PER_STEP: (MessageBlock | null)[][] = [
|
||||
[],
|
||||
[{ role: "user", detail: "Fix the login bug", colorClass: "bg-blue-500 dark:bg-blue-600" }],
|
||||
[],
|
||||
[{ role: "assistant", detail: "tool_use: read_file", colorClass: "bg-zinc-600 dark:bg-zinc-500" }],
|
||||
[{ role: "tool_result", detail: "auth.ts contents...", colorClass: "bg-emerald-500 dark:bg-emerald-600" }],
|
||||
[
|
||||
{ role: "assistant", detail: "tool_use: edit_file", colorClass: "bg-zinc-600 dark:bg-zinc-500" },
|
||||
{ role: "tool_result", detail: "file updated", colorClass: "bg-emerald-500 dark:bg-emerald-600" },
|
||||
],
|
||||
[{ role: "assistant", detail: "end_turn: Done!", colorClass: "bg-purple-500 dark:bg-purple-600" }],
|
||||
];
|
||||
|
||||
// -- Step annotations --
|
||||
|
||||
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: "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." },
|
||||
];
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function getNode(id: string): FlowNode {
|
||||
return NODES.find((n) => n.id === id)!;
|
||||
}
|
||||
|
||||
function edgePath(fromId: string, toId: string): string {
|
||||
const from = getNode(fromId);
|
||||
const to = getNode(toId);
|
||||
|
||||
// Loop-back: append -> api_call (goes to the left side and back up)
|
||||
if (fromId === "append" && toId === "api_call") {
|
||||
const startX = from.x - from.w / 2;
|
||||
const startY = from.y;
|
||||
const endX = to.x - to.w / 2;
|
||||
const endY = to.y;
|
||||
return `M ${startX} ${startY} L ${startX - 50} ${startY} L ${endX - 50} ${endY} L ${endX} ${endY}`;
|
||||
}
|
||||
|
||||
// Horizontal: check -> end
|
||||
if (fromId === "check" && toId === "end") {
|
||||
const startX = from.x + from.w / 2;
|
||||
const startY = from.y;
|
||||
const endX = to.x - to.w / 2;
|
||||
const endY = to.y;
|
||||
return `M ${startX} ${startY} L ${endX} ${endY}`;
|
||||
}
|
||||
|
||||
// Vertical (default)
|
||||
const startX = from.x;
|
||||
const startY = from.y + from.h / 2;
|
||||
const endX = to.x;
|
||||
const endY = to.y - to.h / 2;
|
||||
return `M ${startX} ${startY} L ${endX} ${endY}`;
|
||||
}
|
||||
|
||||
// -- Component --
|
||||
|
||||
export default function AgentLoop({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
|
||||
|
||||
const palette = useSvgPalette();
|
||||
const activeNodes = ACTIVE_NODES_PER_STEP[currentStep];
|
||||
const activeEdges = ACTIVE_EDGES_PER_STEP[currentStep];
|
||||
|
||||
// Build accumulated messages up to the current step
|
||||
const visibleMessages: MessageBlock[] = [];
|
||||
for (let s = 0; s <= currentStep; s++) {
|
||||
for (const msg of MESSAGES_PER_STEP[s]) {
|
||||
if (msg) visibleMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "The Agent While-Loop"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
{/* 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")
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 500 440"
|
||||
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<defs>
|
||||
<filter id="glow-blue">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.7" />
|
||||
</filter>
|
||||
<filter id="glow-purple">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#a855f7" floodOpacity="0.7" />
|
||||
</filter>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="8"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowhead-active"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="8"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Edges */}
|
||||
{EDGES.map((edge) => {
|
||||
const key = `${edge.from}->${edge.to}`;
|
||||
const isActive = activeEdges.includes(key);
|
||||
const d = edgePath(edge.from, edge.to);
|
||||
|
||||
return (
|
||||
<g key={key}>
|
||||
<motion.path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={isActive ? palette.activeEdgeStroke : palette.edgeStroke}
|
||||
strokeWidth={isActive ? 2.5 : 1.5}
|
||||
strokeDasharray={isActive ? "none" : "none"}
|
||||
markerEnd={isActive ? "url(#arrowhead-active)" : "url(#arrowhead)"}
|
||||
animate={{
|
||||
stroke: isActive ? palette.activeEdgeStroke : palette.edgeStroke,
|
||||
strokeWidth: isActive ? 2.5 : 1.5,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
{edge.label && (
|
||||
<text
|
||||
x={
|
||||
edge.from === "check" && edge.to === "end"
|
||||
? (getNode("check").x + getNode("end").x) / 2
|
||||
: getNode(edge.from).x + 75
|
||||
}
|
||||
y={
|
||||
edge.from === "check" && edge.to === "end"
|
||||
? getNode("check").y - 10
|
||||
: (getNode(edge.from).y + getNode(edge.to).y) / 2
|
||||
}
|
||||
textAnchor="middle"
|
||||
className="fill-zinc-400 text-[10px] dark:fill-zinc-500"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{NODES.map((node) => {
|
||||
const isActive = activeNodes.includes(node.id);
|
||||
const isEnd = node.id === "end";
|
||||
const filterAttr = isActive
|
||||
? isEnd
|
||||
? "url(#glow-purple)"
|
||||
: "url(#glow-blue)"
|
||||
: "none";
|
||||
|
||||
if (node.type === "diamond") {
|
||||
// Diamond shape for decision node
|
||||
const cx = node.x;
|
||||
const cy = node.y;
|
||||
const hw = node.w / 2;
|
||||
const hh = node.h / 2;
|
||||
const points = `${cx},${cy - hh} ${cx + hw},${cy} ${cx},${cy + hh} ${cx - hw},${cy}`;
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<motion.polygon
|
||||
points={points}
|
||||
rx={6}
|
||||
fill={isActive ? palette.activeNodeFill : palette.nodeFill}
|
||||
stroke={isActive ? palette.activeNodeStroke : palette.nodeStroke}
|
||||
strokeWidth={1.5}
|
||||
filter={filterAttr}
|
||||
animate={{
|
||||
fill: isActive ? palette.activeNodeFill : palette.nodeFill,
|
||||
stroke: isActive ? palette.activeNodeStroke : palette.nodeStroke,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={cx}
|
||||
y={cy + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={11}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: isActive ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{node.label}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<motion.rect
|
||||
x={node.x - node.w / 2}
|
||||
y={node.y - node.h / 2}
|
||||
width={node.w}
|
||||
height={node.h}
|
||||
rx={8}
|
||||
fill={isActive ? (isEnd ? palette.endNodeFill : palette.activeNodeFill) : palette.nodeFill}
|
||||
stroke={isActive ? (isEnd ? palette.endNodeStroke : palette.activeNodeStroke) : palette.nodeStroke}
|
||||
strokeWidth={1.5}
|
||||
filter={filterAttr}
|
||||
animate={{
|
||||
fill: isActive ? (isEnd ? palette.endNodeFill : palette.activeNodeFill) : palette.nodeFill,
|
||||
stroke: isActive ? (isEnd ? palette.endNodeStroke : palette.activeNodeStroke) : palette.nodeStroke,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={node.x}
|
||||
y={node.y + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: isActive ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{node.label}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Iteration counter */}
|
||||
{currentStep >= 5 && (
|
||||
<motion.text
|
||||
x={60}
|
||||
y={130}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fontFamily="monospace"
|
||||
fill="#3b82f6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
iter #2
|
||||
</motion.text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right panel: messages[] array (40%) */}
|
||||
<div className="w-full lg:w-[40%]">
|
||||
<div className="mb-2 font-mono text-xs text-zinc-400 dark:text-zinc-500">
|
||||
messages[]
|
||||
</div>
|
||||
<div className="min-h-[300px] space-y-2 rounded-md border border-zinc-100 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleMessages.length === 0 && (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="py-8 text-center text-xs text-zinc-400 dark:text-zinc-600"
|
||||
>
|
||||
[ empty ]
|
||||
</motion.div>
|
||||
)}
|
||||
{visibleMessages.map((msg, i) => (
|
||||
<motion.div
|
||||
key={`${msg.role}-${msg.detail}-${i}`}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.35, type: "spring", bounce: 0.3 }}
|
||||
className={`rounded-md px-3 py-2 ${msg.colorClass}`}
|
||||
>
|
||||
<div className="font-mono text-[11px] font-semibold text-white">
|
||||
{msg.role}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-white/80">
|
||||
{msg.detail}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Array index markers */}
|
||||
{visibleMessages.length > 0 && (
|
||||
<div className="mt-3 border-t border-zinc-200 pt-2 dark:border-zinc-700">
|
||||
<span className="font-mono text-[10px] text-zinc-400">
|
||||
length: {visibleMessages.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
// -- Tool definitions --
|
||||
|
||||
interface ToolDef {
|
||||
name: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
activeColor: string;
|
||||
darkColor: string;
|
||||
darkActiveColor: string;
|
||||
}
|
||||
|
||||
const TOOLS: ToolDef[] = [
|
||||
{
|
||||
name: "bash",
|
||||
desc: "Execute shell commands",
|
||||
color: "border-orange-300 bg-orange-50",
|
||||
activeColor: "border-orange-500 bg-orange-100 ring-2 ring-orange-400",
|
||||
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
|
||||
darkActiveColor: "dark:border-orange-500 dark:bg-orange-950/40 dark:ring-orange-500",
|
||||
},
|
||||
{
|
||||
name: "read_file",
|
||||
desc: "Read file contents",
|
||||
color: "border-sky-300 bg-sky-50",
|
||||
activeColor: "border-sky-500 bg-sky-100 ring-2 ring-sky-400",
|
||||
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
|
||||
darkActiveColor: "dark:border-sky-500 dark:bg-sky-950/40 dark:ring-sky-500",
|
||||
},
|
||||
{
|
||||
name: "write_file",
|
||||
desc: "Create or overwrite a file",
|
||||
color: "border-emerald-300 bg-emerald-50",
|
||||
activeColor: "border-emerald-500 bg-emerald-100 ring-2 ring-emerald-400",
|
||||
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
|
||||
darkActiveColor: "dark:border-emerald-500 dark:bg-emerald-950/40 dark:ring-emerald-500",
|
||||
},
|
||||
{
|
||||
name: "edit_file",
|
||||
desc: "Apply targeted edits",
|
||||
color: "border-violet-300 bg-violet-50",
|
||||
activeColor: "border-violet-500 bg-violet-100 ring-2 ring-violet-400",
|
||||
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
|
||||
darkActiveColor: "dark:border-violet-500 dark:bg-violet-950/40 dark:ring-violet-500",
|
||||
},
|
||||
];
|
||||
|
||||
// Per-step: which tool index is active (-1 = none, 4 = all)
|
||||
const ACTIVE_TOOL_PER_STEP: number[] = [-1, 0, 1, 2, 3, 4];
|
||||
|
||||
// Incoming request JSON per step
|
||||
const REQUEST_PER_STEP: (string | null)[] = [
|
||||
null,
|
||||
'{ name: "bash", input: { cmd: "ls -la" } }',
|
||||
'{ name: "read_file", input: { path: "src/auth.ts" } }',
|
||||
'{ name: "write_file", input: { path: "config.json" } }',
|
||||
'{ name: "edit_file", input: { path: "index.ts" } }',
|
||||
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." },
|
||||
];
|
||||
|
||||
// SVG layout constants
|
||||
const SVG_WIDTH = 600;
|
||||
const SVG_HEIGHT = 320;
|
||||
const DISPATCHER_X = SVG_WIDTH / 2;
|
||||
const DISPATCHER_Y = 60;
|
||||
const DISPATCHER_W = 160;
|
||||
const DISPATCHER_H = 50;
|
||||
const CARD_Y = 230;
|
||||
const CARD_W = 110;
|
||||
const CARD_H = 65;
|
||||
const CARD_GAP = 20;
|
||||
|
||||
function getCardX(index: number): number {
|
||||
const totalWidth = TOOLS.length * CARD_W + (TOOLS.length - 1) * CARD_GAP;
|
||||
const startX = (SVG_WIDTH - totalWidth) / 2;
|
||||
return startX + index * (CARD_W + CARD_GAP) + CARD_W / 2;
|
||||
}
|
||||
|
||||
export default function ToolDispatch({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 6, autoPlayInterval: 2500 });
|
||||
|
||||
const palette = useSvgPalette();
|
||||
const activeToolIdx = ACTIVE_TOOL_PER_STEP[currentStep];
|
||||
const request = REQUEST_PER_STEP[currentStep];
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
const isAllActive = activeToolIdx === 4;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Tool Dispatch Map"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* Incoming request display */}
|
||||
<div className="mb-4 flex min-h-[32px] items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Incoming:
|
||||
</span>
|
||||
<AnimatePresence mode="wait">
|
||||
{request && (
|
||||
<motion.code
|
||||
key={request}
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded bg-blue-100 px-2.5 py-1 font-mono text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
{request}
|
||||
</motion.code>
|
||||
)}
|
||||
{!request && currentStep === 0 && (
|
||||
<motion.span
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
className="text-xs text-zinc-400 dark:text-zinc-600"
|
||||
>
|
||||
waiting for tool_call...
|
||||
</motion.span>
|
||||
)}
|
||||
{isAllActive && (
|
||||
<motion.span
|
||||
key="all-routes"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
All routes active
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* SVG dispatch diagram */}
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
||||
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
|
||||
style={{ minHeight: 240 }}
|
||||
>
|
||||
<defs>
|
||||
<filter id="dispatch-glow">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="card-glow-orange">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#f97316" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="card-glow-sky">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#0ea5e9" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="card-glow-emerald">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#10b981" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<filter id="card-glow-violet">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#8b5cf6" floodOpacity="0.6" />
|
||||
</filter>
|
||||
<marker
|
||||
id="dispatch-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="8"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
|
||||
</marker>
|
||||
<marker
|
||||
id="dispatch-arrow-dim"
|
||||
markerWidth="8"
|
||||
markerHeight="6"
|
||||
refX="8"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Dispatcher box */}
|
||||
<motion.rect
|
||||
x={DISPATCHER_X - DISPATCHER_W / 2}
|
||||
y={DISPATCHER_Y - DISPATCHER_H / 2}
|
||||
width={DISPATCHER_W}
|
||||
height={DISPATCHER_H}
|
||||
rx={10}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: currentStep > 0 ? palette.activeNodeFill : palette.nodeFill,
|
||||
stroke: currentStep > 0 ? palette.activeNodeStroke : palette.nodeStroke,
|
||||
}}
|
||||
filter={currentStep > 0 ? "url(#dispatch-glow)" : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={DISPATCHER_X}
|
||||
y={DISPATCHER_Y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={13}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: currentStep > 0 ? palette.activeNodeText : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
dispatch(name)
|
||||
</motion.text>
|
||||
|
||||
{/* Connection lines from dispatcher to each tool card */}
|
||||
{TOOLS.map((tool, i) => {
|
||||
const cardX = getCardX(i);
|
||||
const isActive = isAllActive || i === activeToolIdx;
|
||||
const lineColor = isActive ? palette.activeEdgeStroke : palette.edgeStroke;
|
||||
|
||||
return (
|
||||
<motion.line
|
||||
key={`line-${tool.name}`}
|
||||
x1={DISPATCHER_X}
|
||||
y1={DISPATCHER_Y + DISPATCHER_H / 2}
|
||||
x2={cardX}
|
||||
y2={CARD_Y - CARD_H / 2}
|
||||
strokeWidth={isActive ? 2.5 : 1.5}
|
||||
markerEnd={isActive ? "url(#dispatch-arrow)" : "url(#dispatch-arrow-dim)"}
|
||||
animate={{ stroke: lineColor, strokeWidth: isActive ? 2.5 : 1.5 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tool cards */}
|
||||
{TOOLS.map((tool, i) => {
|
||||
const cardX = getCardX(i);
|
||||
const isActive = isAllActive || i === activeToolIdx;
|
||||
const glowFilters = [
|
||||
"url(#card-glow-orange)",
|
||||
"url(#card-glow-sky)",
|
||||
"url(#card-glow-emerald)",
|
||||
"url(#card-glow-violet)",
|
||||
];
|
||||
const activeColors = ["#f97316", "#0ea5e9", "#10b981", "#8b5cf6"];
|
||||
const activeBorders = ["#ea580c", "#0284c7", "#059669", "#7c3aed"];
|
||||
|
||||
return (
|
||||
<g key={tool.name}>
|
||||
<motion.rect
|
||||
x={cardX - CARD_W / 2}
|
||||
y={CARD_Y - CARD_H / 2}
|
||||
width={CARD_W}
|
||||
height={CARD_H}
|
||||
rx={8}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
fill: isActive ? activeColors[i] : palette.nodeFill,
|
||||
stroke: isActive ? activeBorders[i] : palette.nodeStroke,
|
||||
}}
|
||||
filter={isActive ? glowFilters[i] : "none"}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={cardX}
|
||||
y={CARD_Y - 8}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="monospace"
|
||||
animate={{ fill: isActive ? "#ffffff" : palette.nodeText }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{tool.name}
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x={cardX}
|
||||
y={CARD_Y + 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fontFamily="sans-serif"
|
||||
animate={{ fill: isActive ? "rgba(255,255,255,0.8)" : palette.labelFill }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{tool.desc}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "+" extensibility indicator on last step */}
|
||||
{isAllActive && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
>
|
||||
<circle
|
||||
cx={getCardX(3) + CARD_W / 2 + 30}
|
||||
cy={CARD_Y}
|
||||
r={16}
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
<text
|
||||
x={getCardX(3) + CARD_W / 2 + 30}
|
||||
y={CARD_Y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={18}
|
||||
fontWeight={700}
|
||||
fill="#3b82f6"
|
||||
>
|
||||
+
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Code snippet below the diagram */}
|
||||
<div className="mt-3 rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
|
||||
<code className="block font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300">
|
||||
<span className="text-blue-600 dark:text-blue-400">const</span> handlers = {"{"}
|
||||
{TOOLS.map((tool, i) => {
|
||||
const isActive = isAllActive || i === activeToolIdx;
|
||||
return (
|
||||
<motion.span
|
||||
key={tool.name}
|
||||
animate={{
|
||||
color: isActive ? "#3b82f6" : undefined,
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
}}
|
||||
className="text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{" "}{tool.name},
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
{" }{"}{"}"};
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
|
||||
// -- Task definitions --
|
||||
|
||||
type TaskStatus = "pending" | "in_progress" | "done";
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
label: string;
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
// Snapshot of all 4 tasks at each step
|
||||
const TASK_STATES: Task[][] = [
|
||||
// Step 0: all pending
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "pending" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "pending" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 1: still all pending (idle round 1)
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "pending" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "pending" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 2: still all pending (idle round 2)
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "pending" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "pending" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 3: NAG fires, task 1 moves to in_progress
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "in_progress" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "pending" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 4: task 1 done
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "done" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "pending" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 5: task 2 self-directed to in_progress
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "done" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "in_progress" },
|
||||
{ id: 3, label: "Add error handling", status: "pending" },
|
||||
{ id: 4, label: "Update config loader", status: "pending" },
|
||||
],
|
||||
// Step 6: tasks 2,3 done, task 4 in_progress
|
||||
[
|
||||
{ id: 1, label: "Write auth tests", status: "done" },
|
||||
{ id: 2, label: "Fix mobile layout", status: "done" },
|
||||
{ id: 3, label: "Add error handling", status: "done" },
|
||||
{ id: 4, label: "Update config loader", status: "in_progress" },
|
||||
],
|
||||
];
|
||||
|
||||
// Nag timer value at each step (out of 3)
|
||||
const NAG_TIMER_PER_STEP = [0, 1, 2, 3, 0, 0, 0];
|
||||
const NAG_THRESHOLD = 3;
|
||||
|
||||
// Whether the nag fires at this step
|
||||
const NAG_FIRES_PER_STEP = [false, false, false, true, false, false, false];
|
||||
|
||||
// Step annotations
|
||||
const STEP_INFO = [
|
||||
{ title: "The Plan", desc: "TodoWrite gives the model a visible plan. All tasks start as pending." },
|
||||
{ title: "Round 1 -- Idle", desc: "The model does work but doesn't touch its todos. The nag counter increments." },
|
||||
{ title: "Round 2 -- Still Idle", desc: "Two rounds without progress. Pressure builds." },
|
||||
{ title: "NAG!", desc: "Threshold reached! System message injected: 'You have pending tasks. Pick one up now!'" },
|
||||
{ title: "Task Complete", desc: "The model completes the task. Timer stays at 0 -- working on todos resets the counter." },
|
||||
{ title: "Self-Directed", desc: "Once the model learns the pattern, it picks up tasks voluntarily." },
|
||||
{ title: "Mission Accomplished", desc: "Visible plan + nag pressure = reliable task completion." },
|
||||
];
|
||||
|
||||
// -- Column component --
|
||||
|
||||
function KanbanColumn({
|
||||
title,
|
||||
tasks,
|
||||
accentClass,
|
||||
headerBg,
|
||||
}: {
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
accentClass: string;
|
||||
headerBg: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div
|
||||
className={`rounded-t-lg px-3 py-2 text-center text-xs font-bold uppercase tracking-wider ${headerBg}`}
|
||||
>
|
||||
{title}
|
||||
<span className={`ml-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-bold ${accentClass}`}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center text-xs text-zinc-400 dark:text-zinc-600">
|
||||
--
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Task card --
|
||||
|
||||
function TaskCard({ task }: { task: Task }) {
|
||||
const statusStyles: Record<TaskStatus, string> = {
|
||||
pending: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
||||
in_progress: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
done: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
const borderStyles: Record<TaskStatus, string> = {
|
||||
pending: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800",
|
||||
in_progress: "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30",
|
||||
done: "border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950/30",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
layoutId={`task-${task.id}`}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className={`rounded-md border p-2.5 ${borderStyles[task.status]}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
#{task.id}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${statusStyles[task.status]}`}
|
||||
>
|
||||
{task.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{task.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Nag gauge --
|
||||
|
||||
function NagGauge({ value, max, firing }: { value: number; max: number; firing: boolean }) {
|
||||
const pct = Math.min((value / max) * 100, 100);
|
||||
|
||||
const barColor =
|
||||
value === 0
|
||||
? "bg-zinc-300 dark:bg-zinc-600"
|
||||
: value === 1
|
||||
? "bg-green-400 dark:bg-green-500"
|
||||
: value === 2
|
||||
? "bg-yellow-400 dark:bg-yellow-500"
|
||||
: "bg-red-500 dark:bg-red-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Nag Timer
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{value}/{max}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<motion.div
|
||||
className={`absolute inset-y-0 left-0 rounded-full ${barColor}`}
|
||||
initial={{ width: "0%" }}
|
||||
animate={{
|
||||
width: `${pct}%`,
|
||||
...(firing ? { scale: [1, 1.05, 1] } : {}),
|
||||
}}
|
||||
transition={{
|
||||
width: { duration: 0.5, ease: "easeOut" },
|
||||
scale: { duration: 0.3, repeat: 2 },
|
||||
}}
|
||||
/>
|
||||
{firing && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-red-500"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0, 1, 0] }}
|
||||
transition={{ duration: 1 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -- Main component --
|
||||
|
||||
export default function TodoWrite({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
|
||||
|
||||
const tasks = TASK_STATES[currentStep];
|
||||
const nagValue = NAG_TIMER_PER_STEP[currentStep];
|
||||
const nagFires = NAG_FIRES_PER_STEP[currentStep];
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
const pendingTasks = tasks.filter((t) => t.status === "pending");
|
||||
const inProgressTasks = tasks.filter((t) => t.status === "in_progress");
|
||||
const doneTasks = tasks.filter((t) => t.status === "done");
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "TodoWrite Nag System"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* Nag gauge + nag message */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<NagGauge value={nagValue} max={NAG_THRESHOLD} firing={nagFires} />
|
||||
|
||||
<AnimatePresence>
|
||||
{nagFires && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||
className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-center text-xs font-bold text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-300"
|
||||
>
|
||||
SYSTEM: "You have pending tasks. Pick one up now!"
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Kanban board */}
|
||||
<div className="flex gap-3">
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
tasks={pendingTasks}
|
||||
accentClass="bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
headerBg="bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="In Progress"
|
||||
tasks={inProgressTasks}
|
||||
accentClass="bg-amber-200 text-amber-700 dark:bg-amber-800 dark:text-amber-200"
|
||||
headerBg="bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
tasks={doneTasks}
|
||||
accentClass="bg-emerald-200 text-emerald-700 dark:bg-emerald-800 dark:text-emerald-200"
|
||||
headerBg="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
<div className="mt-3 flex items-center justify-between rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
|
||||
<span className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
Progress: {doneTasks.length}/{tasks.length} complete
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{tasks.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`h-2 w-6 rounded-sm ${
|
||||
t.status === "done"
|
||||
? "bg-emerald-500"
|
||||
: t.status === "in_progress"
|
||||
? "bg-amber-400"
|
||||
: "bg-zinc-300 dark:bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.desc}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
|
||||
interface MessageBlock {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const PARENT_BASE_MESSAGES: MessageBlock[] = [
|
||||
{ id: "p1", label: "user: Build login + tests", color: "bg-blue-500" },
|
||||
{ id: "p2", label: "assistant: Planning approach...", color: "bg-zinc-600" },
|
||||
{ id: "p3", label: "tool_result: project structure", color: "bg-emerald-500" },
|
||||
];
|
||||
|
||||
const TASK_PROMPT: MessageBlock = {
|
||||
id: "task",
|
||||
label: "task: Write unit tests for auth",
|
||||
color: "bg-purple-500",
|
||||
};
|
||||
|
||||
const CHILD_WORK_MESSAGES: MessageBlock[] = [
|
||||
{ id: "c1", label: "tool_use: read auth.ts", color: "bg-amber-500" },
|
||||
{ id: "c2", label: "tool_use: write test.ts", color: "bg-amber-500" },
|
||||
];
|
||||
|
||||
const SUMMARY_BLOCK: MessageBlock = {
|
||||
id: "summary",
|
||||
label: "summary: 3 tests written, all passing",
|
||||
color: "bg-teal-500",
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Parent Context",
|
||||
description:
|
||||
"The parent agent has accumulated messages from the conversation.",
|
||||
},
|
||||
{
|
||||
title: "Spawn Subagent",
|
||||
description:
|
||||
"Task tool creates a child with fresh messages[]. Only the task description is passed.",
|
||||
},
|
||||
{
|
||||
title: "Independent Work",
|
||||
description:
|
||||
"The child has its own context. It doesn't see the parent's history.",
|
||||
},
|
||||
{
|
||||
title: "Compress Result",
|
||||
description:
|
||||
"The child's full conversation compresses into one summary.",
|
||||
},
|
||||
{
|
||||
title: "Return Summary",
|
||||
description:
|
||||
"Only the summary returns. The child's full context is discarded.",
|
||||
},
|
||||
{
|
||||
title: "Clean Context",
|
||||
description:
|
||||
"The parent gets a clean summary without context bloat. This is process isolation for LLMs.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SubagentIsolation({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
|
||||
// Derive what to show in each container based on step
|
||||
const parentMessages: MessageBlock[] = (() => {
|
||||
const base = [...PARENT_BASE_MESSAGES];
|
||||
if (currentStep >= 5) {
|
||||
base.push(SUMMARY_BLOCK);
|
||||
}
|
||||
return base;
|
||||
})();
|
||||
|
||||
const childMessages: MessageBlock[] = (() => {
|
||||
if (currentStep < 1) return [];
|
||||
if (currentStep === 1) return [TASK_PROMPT];
|
||||
if (currentStep === 2) return [TASK_PROMPT, ...CHILD_WORK_MESSAGES];
|
||||
if (currentStep === 3) return [SUMMARY_BLOCK];
|
||||
return currentStep >= 4 ? [TASK_PROMPT, ...CHILD_WORK_MESSAGES] : [];
|
||||
})();
|
||||
|
||||
const showChildEmpty = currentStep === 0;
|
||||
const showArcToChild = currentStep === 1;
|
||||
const showCompression = currentStep === 3;
|
||||
const showArcToParent = currentStep === 4;
|
||||
const childDiscarded = currentStep >= 4;
|
||||
const childFaded = currentStep >= 4;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Subagent Context Isolation"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
style={{ minHeight: 500 }}
|
||||
>
|
||||
{/* Main layout: two containers side by side */}
|
||||
<div className="relative flex gap-4" style={{ minHeight: 340 }}>
|
||||
{/* Parent Process Container */}
|
||||
<div className="flex-1 rounded-xl border-2 border-blue-300 bg-blue-50/50 p-4 dark:border-blue-700 dark:bg-blue-950/20">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-blue-500" />
|
||||
<span className="text-sm font-bold text-blue-700 dark:text-blue-300">
|
||||
Parent Process
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 font-mono text-xs text-zinc-400">
|
||||
messages[]
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
{parentMessages.map((msg, i) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -12 }}
|
||||
transition={{ duration: 0.4, delay: msg.id === "summary" ? 0.3 : 0 }}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium text-white shadow-sm ${msg.color}`}
|
||||
>
|
||||
{msg.label}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{currentStep >= 5 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-3 rounded border border-blue-200 bg-white/60 px-2 py-1 text-center text-xs text-blue-600 dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
|
||||
>
|
||||
3 original + 1 summary = clean context
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Isolation Wall */}
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="h-full w-px border-l-2 border-dashed border-zinc-300 dark:border-zinc-600" />
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: currentStep >= 1 && currentStep <= 4 ? 1 : 0.4,
|
||||
}}
|
||||
className="rounded bg-zinc-200 px-2 py-1 text-center font-mono text-[10px] text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400"
|
||||
style={{ writingMode: "vertical-rl", textOrientation: "mixed" }}
|
||||
>
|
||||
ISOLATION
|
||||
</motion.div>
|
||||
<div className="h-full w-px border-l-2 border-dashed border-zinc-300 dark:border-zinc-600" />
|
||||
</div>
|
||||
|
||||
{/* Child Process Container */}
|
||||
<div
|
||||
className={`flex-1 rounded-xl border-2 p-4 transition-colors duration-300 ${
|
||||
showChildEmpty
|
||||
? "border-dashed border-zinc-300 bg-zinc-50/50 dark:border-zinc-600 dark:bg-zinc-800/30"
|
||||
: childDiscarded
|
||||
? "border-zinc-300 bg-zinc-100/50 dark:border-zinc-600 dark:bg-zinc-800/40"
|
||||
: "border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${
|
||||
showChildEmpty
|
||||
? "bg-zinc-300 dark:bg-zinc-600"
|
||||
: childDiscarded
|
||||
? "bg-zinc-400 dark:bg-zinc-500"
|
||||
: "bg-purple-500"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-bold ${
|
||||
showChildEmpty
|
||||
? "text-zinc-400 dark:text-zinc-500"
|
||||
: childDiscarded
|
||||
? "text-zinc-400 dark:text-zinc-500"
|
||||
: "text-purple-700 dark:text-purple-300"
|
||||
}`}
|
||||
>
|
||||
Child Process
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 font-mono text-xs text-zinc-400">
|
||||
messages[] (fresh)
|
||||
</div>
|
||||
|
||||
{showChildEmpty && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex h-24 items-center justify-center rounded-lg border border-dashed border-zinc-200 dark:border-zinc-700"
|
||||
>
|
||||
<span className="text-xs text-zinc-400">
|
||||
not yet spawned
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence>
|
||||
{childMessages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id + "-child"}
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: childFaded ? 0.3 : 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium text-white shadow-sm ${msg.color}`}
|
||||
>
|
||||
{msg.label}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{showCompression && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="mt-3 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-center text-xs text-amber-700 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
Compressing full context into summary...
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{childDiscarded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-3 rounded border border-red-200 bg-red-50 px-2 py-1 text-center text-xs text-red-500 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
context discarded
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animated arcs: task prompt going from parent to child */}
|
||||
<AnimatePresence>
|
||||
{showArcToChild && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: "20%", y: "-10%" }}
|
||||
animate={{ opacity: 1, x: "55%", y: "-10%" }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.0, ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute left-0 top-0"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<div className="rounded-lg bg-purple-500 px-3 py-1.5 text-xs font-medium text-white shadow-lg">
|
||||
task prompt
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showArcToParent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: "75%", y: "60%" }}
|
||||
animate={{ opacity: 1, x: "15%", y: "60%" }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1.0, ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute left-0 top-0"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<div className="rounded-lg bg-teal-500 px-3 py-1.5 text-xs font-medium text-white shadow-lg">
|
||||
summary
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Step Controls */}
|
||||
<div className="mt-6">
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={STEPS[currentStep].title}
|
||||
stepDescription={STEPS[currentStep].description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
|
||||
interface SkillEntry {
|
||||
name: string;
|
||||
summary: string;
|
||||
fullTokens: number;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
const SKILLS: SkillEntry[] = [
|
||||
{
|
||||
name: "/commit",
|
||||
summary: "Create git commits following repo conventions",
|
||||
fullTokens: 320,
|
||||
content: [
|
||||
"1. Run git status + git diff to see changes",
|
||||
"2. Analyze all staged changes and draft message",
|
||||
"3. Create commit with Co-Authored-By trailer",
|
||||
"4. Run git status after commit to verify",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "/review-pr",
|
||||
summary: "Review pull requests for bugs and style",
|
||||
fullTokens: 480,
|
||||
content: [
|
||||
"1. Fetch PR diff via gh pr view",
|
||||
"2. Analyze changes file by file for issues",
|
||||
"3. Check for bugs, security, and style problems",
|
||||
"4. Post review comments with gh pr review",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "/test",
|
||||
summary: "Run and analyze test suites",
|
||||
fullTokens: 290,
|
||||
content: [
|
||||
"1. Detect test framework from package.json",
|
||||
"2. Run test suite and capture output",
|
||||
"3. Analyze failures and suggest fixes",
|
||||
"4. Re-run after applying fixes",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "/deploy",
|
||||
summary: "Deploy application to target environment",
|
||||
fullTokens: 350,
|
||||
content: [
|
||||
"1. Verify all tests pass before deploy",
|
||||
"2. Build production bundle",
|
||||
"3. Push to deployment target via CI",
|
||||
"4. Verify health check on deployed URL",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TOKEN_STATES = [120, 120, 440, 440, 780, 780];
|
||||
const MAX_TOKEN_DISPLAY = 1000;
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Layer 1: Compact Summaries",
|
||||
description:
|
||||
"All skills are summarized in the system prompt. Compact, always present.",
|
||||
},
|
||||
{
|
||||
title: "Skill Invocation",
|
||||
description:
|
||||
'The model recognizes a skill invocation and triggers the Skill tool.',
|
||||
},
|
||||
{
|
||||
title: "Layer 2: Full Injection",
|
||||
description:
|
||||
"The full skill instructions are injected as a tool_result, not into the system prompt.",
|
||||
},
|
||||
{
|
||||
title: "In Context Now",
|
||||
description:
|
||||
"The detailed instructions appear as if a tool returned them. The model follows them precisely.",
|
||||
},
|
||||
{
|
||||
title: "Stack Skills",
|
||||
description:
|
||||
"Multiple skills can be loaded. Only summaries are permanent; full content comes and goes.",
|
||||
},
|
||||
{
|
||||
title: "Two-Layer Architecture",
|
||||
description:
|
||||
"Layer 1: always present, tiny. Layer 2: loaded on demand, detailed. Elegant separation.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SkillLoading({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
|
||||
const tokenCount = TOKEN_STATES[currentStep];
|
||||
const highlightedSkill = currentStep >= 1 && currentStep <= 3 ? 0 : currentStep >= 4 ? 1 : -1;
|
||||
const showFirstContent = currentStep >= 2;
|
||||
const showSecondContent = currentStep >= 4;
|
||||
const firstContentFaded = currentStep >= 5;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "On-Demand Skill Loading"}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
style={{ minHeight: 500 }}
|
||||
>
|
||||
<div className="flex gap-6">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{/* System Prompt Block */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-zinc-400" />
|
||||
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
System Prompt
|
||||
</span>
|
||||
<span className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[10px] text-zinc-400 dark:bg-zinc-800">
|
||||
always present
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-300 bg-zinc-900 p-4 dark:border-zinc-600">
|
||||
<div className="mb-2 font-mono text-[10px] text-zinc-500">
|
||||
# Available Skills
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{SKILLS.map((skill, i) => {
|
||||
const isHighlighted = i === highlightedSkill;
|
||||
return (
|
||||
<motion.div
|
||||
key={skill.name}
|
||||
animate={{
|
||||
boxShadow: isHighlighted
|
||||
? "0 0 12px 2px rgba(59, 130, 246, 0.5)"
|
||||
: "0 0 0 0px rgba(59, 130, 246, 0)",
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`rounded px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
isHighlighted
|
||||
? "bg-blue-900/60 text-blue-300"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold text-zinc-200">
|
||||
{skill.name}
|
||||
</span>
|
||||
{" - "}
|
||||
{skill.summary}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User invocation indicator */}
|
||||
<AnimatePresence>
|
||||
{currentStep === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-950/30"
|
||||
>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
User types:
|
||||
</span>
|
||||
<code className="rounded bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-800 dark:bg-blue-900/50 dark:text-blue-200">
|
||||
/commit
|
||||
</code>
|
||||
</motion.div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-950/30"
|
||||
>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">
|
||||
User types:
|
||||
</span>
|
||||
<code className="rounded bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-800 dark:bg-blue-900/50 dark:text-blue-200">
|
||||
/review-pr
|
||||
</code>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Connecting arrow */}
|
||||
<AnimatePresence>
|
||||
{(showFirstContent || showSecondContent) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scaleY: 0 }}
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="h-6 w-px bg-blue-400 dark:bg-blue-500" />
|
||||
<div className="h-0 w-0 border-l-[5px] border-r-[5px] border-t-[6px] border-l-transparent border-r-transparent border-t-blue-400 dark:border-t-blue-500" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Expanded Skill Content Blocks */}
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{showFirstContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{
|
||||
opacity: firstContentFaded ? 0.4 : 1,
|
||||
height: "auto",
|
||||
}}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-lg border-2 border-blue-300 bg-white p-4 dark:border-blue-700 dark:bg-zinc-800">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-xs font-bold text-blue-700 dark:text-blue-300">
|
||||
SKILL.md: /commit
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 font-mono text-[10px] text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
tool_result
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{SKILLS[0].content.map((line, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{
|
||||
opacity: firstContentFaded ? 0.5 : 1,
|
||||
x: 0,
|
||||
}}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="font-mono text-xs text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{line}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSecondContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-lg border-2 border-purple-300 bg-white p-4 dark:border-purple-700 dark:bg-zinc-800">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-500" />
|
||||
<span className="text-xs font-bold text-purple-700 dark:text-purple-300">
|
||||
SKILL.md: /review-pr
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 font-mono text-[10px] text-purple-600 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
tool_result
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{SKILLS[1].content.map((line, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
className="font-mono text-xs text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{line}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Mechanism annotation on step 3 */}
|
||||
<AnimatePresence>
|
||||
{currentStep === 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
The Skill tool returns content as a tool_result message.
|
||||
The model sees it in context and follows the instructions.
|
||||
No system prompt bloat.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Final overview label on step 5 */}
|
||||
<AnimatePresence>
|
||||
{currentStep === 5 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<div className="flex-1 rounded border border-zinc-200 bg-zinc-50 p-2 text-center dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
LAYER 1
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600 dark:text-zinc-300">
|
||||
Always present, ~120 tokens
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 rounded border border-blue-200 bg-blue-50 p-2 text-center dark:border-blue-700 dark:bg-blue-900/20">
|
||||
<div className="text-[10px] font-semibold text-blue-500 dark:text-blue-400">
|
||||
LAYER 2
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-300">
|
||||
On demand, ~300-500 tokens each
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Token Gauge (vertical bar on the right) */}
|
||||
<div className="flex w-16 flex-col items-center">
|
||||
<div className="mb-1 text-center font-mono text-[10px] text-zinc-400">
|
||||
Tokens
|
||||
</div>
|
||||
<div
|
||||
className="relative w-8 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800"
|
||||
style={{ height: 300 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
height: `${(tokenCount / MAX_TOKEN_DISPLAY) * 100}%`,
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={`absolute bottom-0 w-full rounded-full ${
|
||||
tokenCount > 600
|
||||
? "bg-amber-500"
|
||||
: tokenCount > 300
|
||||
? "bg-blue-500"
|
||||
: "bg-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
key={tokenCount}
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mt-2 text-center font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300"
|
||||
>
|
||||
{tokenCount}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Controls */}
|
||||
<div className="mt-6">
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={STEPS[currentStep].title}
|
||||
stepDescription={STEPS[currentStep].description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
|
||||
type BlockType = "user" | "assistant" | "tool_result";
|
||||
|
||||
interface ContextBlock {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
label: string;
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
const BLOCK_COLORS: Record<BlockType, string> = {
|
||||
user: "bg-blue-500",
|
||||
assistant: "bg-zinc-500 dark:bg-zinc-600",
|
||||
tool_result: "bg-emerald-500",
|
||||
};
|
||||
|
||||
const BLOCK_LABELS: Record<BlockType, string> = {
|
||||
user: "USR",
|
||||
assistant: "AST",
|
||||
tool_result: "TRL",
|
||||
};
|
||||
|
||||
function generateBlocks(count: number, seed: number): ContextBlock[] {
|
||||
const types: BlockType[] = ["user", "assistant", "tool_result"];
|
||||
const blocks: ContextBlock[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const typeIndex = (i + seed) % 3;
|
||||
const type = types[typeIndex];
|
||||
const tokens = type === "tool_result" ? 4000 + (i % 3) * 1000 : 1500 + (i % 4) * 500;
|
||||
blocks.push({
|
||||
id: `b-${seed}-${i}`,
|
||||
type,
|
||||
label: `${BLOCK_LABELS[type]} ${i + 1}`,
|
||||
tokens,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const MAX_TOKENS = 100000;
|
||||
const WINDOW_HEIGHT = 350;
|
||||
|
||||
interface StepState {
|
||||
blocks: { id: string; type: BlockType; label: string; heightPx: number; compressed?: boolean }[];
|
||||
tokenCount: number;
|
||||
fillPercent: number;
|
||||
compressionLabel: string | null;
|
||||
}
|
||||
|
||||
function computeStepState(step: number): StepState {
|
||||
switch (step) {
|
||||
case 0: {
|
||||
const raw = generateBlocks(8, 0);
|
||||
const tokenCount = 30000;
|
||||
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
|
||||
const blocks = raw.map((b) => ({
|
||||
...b,
|
||||
heightPx: Math.max(16, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.3),
|
||||
}));
|
||||
return { blocks, tokenCount, fillPercent: 30, compressionLabel: null };
|
||||
}
|
||||
case 1: {
|
||||
const raw = generateBlocks(16, 0);
|
||||
const tokenCount = 60000;
|
||||
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
|
||||
const blocks = raw.map((b) => ({
|
||||
...b,
|
||||
heightPx: Math.max(12, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.6),
|
||||
}));
|
||||
return { blocks, tokenCount, fillPercent: 60, compressionLabel: null };
|
||||
}
|
||||
case 2: {
|
||||
const raw = generateBlocks(20, 0);
|
||||
const tokenCount = 80000;
|
||||
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
|
||||
const blocks = raw.map((b) => ({
|
||||
...b,
|
||||
heightPx: Math.max(10, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.8),
|
||||
}));
|
||||
return { blocks, tokenCount, fillPercent: 80, compressionLabel: null };
|
||||
}
|
||||
case 3: {
|
||||
const raw = generateBlocks(20, 0);
|
||||
const tokenCount = 60000;
|
||||
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
|
||||
const blocks = raw.map((b) => ({
|
||||
...b,
|
||||
heightPx:
|
||||
b.type === "tool_result"
|
||||
? 6
|
||||
: Math.max(12, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.6),
|
||||
compressed: b.type === "tool_result",
|
||||
}));
|
||||
return {
|
||||
blocks,
|
||||
tokenCount,
|
||||
fillPercent: 60,
|
||||
compressionLabel: "MICRO-COMPACT",
|
||||
};
|
||||
}
|
||||
case 4: {
|
||||
const raw = generateBlocks(24, 1);
|
||||
const tokenCount = 85000;
|
||||
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
|
||||
const blocks = raw.map((b) => ({
|
||||
...b,
|
||||
heightPx: Math.max(10, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.85),
|
||||
}));
|
||||
return { blocks, tokenCount, fillPercent: 85, compressionLabel: null };
|
||||
}
|
||||
case 5: {
|
||||
const tokenCount = 25000;
|
||||
const summaryBlock = {
|
||||
id: "auto-summary",
|
||||
type: "assistant" as BlockType,
|
||||
label: "SUMMARY",
|
||||
heightPx: 40,
|
||||
compressed: false,
|
||||
};
|
||||
const recentBlocks = generateBlocks(4, 2).map((b) => ({
|
||||
...b,
|
||||
heightPx: 20,
|
||||
}));
|
||||
return {
|
||||
blocks: [summaryBlock, ...recentBlocks],
|
||||
tokenCount,
|
||||
fillPercent: 25,
|
||||
compressionLabel: "AUTO-COMPACT",
|
||||
};
|
||||
}
|
||||
case 6: {
|
||||
const tokenCount = 8000;
|
||||
const compactBlock = {
|
||||
id: "compact-summary",
|
||||
type: "assistant" as BlockType,
|
||||
label: "COMPACT SUMMARY",
|
||||
heightPx: 24,
|
||||
compressed: false,
|
||||
};
|
||||
return {
|
||||
blocks: [compactBlock],
|
||||
tokenCount,
|
||||
fillPercent: 8,
|
||||
compressionLabel: "/compact",
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { blocks: [], tokenCount: 0, fillPercent: 0, compressionLabel: null };
|
||||
}
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Growing Context",
|
||||
description:
|
||||
"The context window holds the conversation. Each API call adds more messages.",
|
||||
},
|
||||
{
|
||||
title: "Context Growing",
|
||||
description:
|
||||
"As the agent works, messages accumulate. The context window fills up.",
|
||||
},
|
||||
{
|
||||
title: "Approaching Limit",
|
||||
description:
|
||||
"Old tool_results are the biggest consumers. Micro-compact targets these first.",
|
||||
},
|
||||
{
|
||||
title: "Stage 1: Micro-Compact",
|
||||
description:
|
||||
"Replace old tool_results with short summaries. Automatic, transparent to the model.",
|
||||
},
|
||||
{
|
||||
title: "Still Growing",
|
||||
description:
|
||||
"Work continues. Context grows again toward the threshold...",
|
||||
},
|
||||
{
|
||||
title: "Stage 2: Auto-Compact",
|
||||
description:
|
||||
"Entire conversation summarized into a compact block. Triggered at token threshold.",
|
||||
},
|
||||
{
|
||||
title: "Stage 3: /compact",
|
||||
description:
|
||||
"User-triggered, most aggressive. Three layers of strategic forgetting enable infinite sessions.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ContextCompact({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
|
||||
const state = useMemo(() => computeStepState(currentStep), [currentStep]);
|
||||
|
||||
const fillColor =
|
||||
state.fillPercent > 75
|
||||
? "bg-red-500"
|
||||
: state.fillPercent > 45
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
|
||||
const tokenDisplay = `${(state.tokenCount / 1000).toFixed(0)}K`;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Three-Layer Context Compression"}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
style={{ minHeight: 500 }}
|
||||
>
|
||||
<div className="flex gap-6">
|
||||
{/* Token Window (tall vertical bar on the left) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-2 font-mono text-[10px] font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
Context Window
|
||||
</div>
|
||||
<div
|
||||
className="relative w-24 overflow-hidden rounded-xl border-2 border-zinc-300 bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800"
|
||||
style={{ height: WINDOW_HEIGHT }}
|
||||
>
|
||||
{/* Blocks stacked from bottom up */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex flex-col-reverse gap-px p-1">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{state.blocks.map((block) => (
|
||||
<motion.div
|
||||
key={block.id}
|
||||
initial={{ opacity: 0, scaleY: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scaleY: 1,
|
||||
height: block.heightPx,
|
||||
}}
|
||||
exit={{ opacity: 0, scaleY: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`flex w-full items-center justify-center rounded-sm ${
|
||||
block.compressed
|
||||
? "bg-emerald-300 dark:bg-emerald-700"
|
||||
: BLOCK_COLORS[block.type]
|
||||
}`}
|
||||
style={{ originY: 1 }}
|
||||
>
|
||||
{block.heightPx >= 14 && (
|
||||
<span className="truncate px-1 text-[8px] font-medium text-white">
|
||||
{block.label}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Fill level line */}
|
||||
<motion.div
|
||||
animate={{ bottom: `${state.fillPercent}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute left-0 right-0 border-t-2 border-dashed border-red-400 dark:border-red-500"
|
||||
>
|
||||
<span className="absolute -top-4 right-1 font-mono text-[9px] font-bold text-red-500 dark:text-red-400">
|
||||
{state.fillPercent}%
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Token count */}
|
||||
<motion.div
|
||||
key={state.tokenCount}
|
||||
initial={{ scale: 0.85 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="mt-2 font-mono text-sm font-bold text-zinc-700 dark:text-zinc-200"
|
||||
>
|
||||
{tokenDisplay}
|
||||
</motion.div>
|
||||
<div className="font-mono text-[10px] text-zinc-400">
|
||||
/ 100K
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: state display and compression stage */}
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
{/* Top: horizontal token bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Token usage
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500">
|
||||
{state.tokenCount.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<motion.div
|
||||
animate={{ width: `${state.fillPercent}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={`h-full rounded-full ${fillColor}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message type legend */}
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 w-3 rounded bg-blue-500" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">user</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 w-3 rounded bg-zinc-500" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">assistant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 w-3 rounded bg-emerald-500" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">tool_result</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Highlight old tool_results at step 2 */}
|
||||
<AnimatePresence>
|
||||
{currentStep === 2 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-700 dark:bg-amber-900/20"
|
||||
>
|
||||
<div className="text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
tool_results are the largest blocks
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
File contents, command outputs, search results -- each one is thousands of tokens.
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Compression stage label */}
|
||||
<AnimatePresence>
|
||||
{state.compressionLabel && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="mt-4"
|
||||
>
|
||||
<div className={`rounded-lg border-2 p-4 text-center ${
|
||||
currentStep === 3
|
||||
? "border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20"
|
||||
: currentStep === 5
|
||||
? "border-blue-400 bg-blue-50 dark:border-blue-600 dark:bg-blue-900/20"
|
||||
: "border-emerald-400 bg-emerald-50 dark:border-emerald-600 dark:bg-emerald-900/20"
|
||||
}`}>
|
||||
<div className={`text-lg font-black ${
|
||||
currentStep === 3
|
||||
? "text-amber-600 dark:text-amber-300"
|
||||
: currentStep === 5
|
||||
? "text-blue-600 dark:text-blue-300"
|
||||
: "text-emerald-600 dark:text-emerald-300"
|
||||
}`}>
|
||||
{state.compressionLabel}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs ${
|
||||
currentStep === 3
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: currentStep === 5
|
||||
? "text-blue-500 dark:text-blue-400"
|
||||
: "text-emerald-500 dark:text-emerald-400"
|
||||
}`}>
|
||||
{currentStep === 3 && "Old tool_results shrunk to tiny summaries"}
|
||||
{currentStep === 5 && "Full conversation compressed to summary block"}
|
||||
{currentStep === 6 && "Most aggressive compression -- near-empty context"}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Three stages overview on final step */}
|
||||
{currentStep === 6 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded bg-amber-50 px-3 py-1.5 dark:bg-amber-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Stage 1: Micro -- shrink old tool_results
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-amber-500">
|
||||
automatic
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded bg-blue-50 px-3 py-1.5 dark:bg-blue-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Stage 2: Auto -- summarize entire conversation
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-blue-500">
|
||||
at threshold
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded bg-emerald-50 px-3 py-1.5 dark:bg-emerald-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
Stage 3: /compact -- user-triggered, deepest compression
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-emerald-500">
|
||||
manual
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Controls */}
|
||||
<div className="mt-6">
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={STEPS[currentStep].title}
|
||||
stepDescription={STEPS[currentStep].description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||
|
||||
interface TaskNode {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface StepInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const TASKS: TaskNode[] = [
|
||||
{ id: "T1", label: "T1: Setup DB", x: 80, y: 160, deps: [] },
|
||||
{ id: "T2", label: "T2: API routes", x: 280, y: 80, deps: ["T1"] },
|
||||
{ id: "T3", label: "T3: Auth module", x: 280, y: 240, deps: ["T1"] },
|
||||
{ id: "T4", label: "T4: Integration", x: 480, y: 160, deps: ["T2", "T3"] },
|
||||
{ id: "T5", label: "T5: Deploy", x: 650, y: 160, deps: ["T4"] },
|
||||
];
|
||||
|
||||
const NODE_W = 140;
|
||||
const NODE_H = 50;
|
||||
|
||||
const STEP_INFO: StepInfo[] = [
|
||||
{
|
||||
title: "File-Based Tasks",
|
||||
description:
|
||||
"Tasks are stored in JSON files on disk. They survive context compaction -- unlike in-memory state.",
|
||||
},
|
||||
{
|
||||
title: "Start T1",
|
||||
description:
|
||||
"Tasks without dependencies can start immediately. T1 has no blockers.",
|
||||
},
|
||||
{
|
||||
title: "T1 Complete",
|
||||
description: "Completing T1 unblocks its dependents: T2 and T3.",
|
||||
},
|
||||
{
|
||||
title: "Parallel Work",
|
||||
description:
|
||||
"T2 and T3 have no dependency on each other. Both can run simultaneously.",
|
||||
},
|
||||
{
|
||||
title: "Partial Unblock",
|
||||
description:
|
||||
"T4 depends on BOTH T2 and T3. It waits for all blockers to complete.",
|
||||
},
|
||||
{
|
||||
title: "Fully Unblocked",
|
||||
description: "All blockers resolved. T4 can now proceed.",
|
||||
},
|
||||
{
|
||||
title: "Graph Resolved",
|
||||
description:
|
||||
"The entire dependency graph is resolved. File-based persistence means this works across context compressions.",
|
||||
},
|
||||
];
|
||||
|
||||
function getTaskStatus(taskId: string, step: number): TaskStatus {
|
||||
const statusMap: Record<string, TaskStatus[]> = {
|
||||
T1: [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T2: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T3: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T4: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"blocked",
|
||||
"in_progress",
|
||||
"completed",
|
||||
],
|
||||
T5: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"completed",
|
||||
],
|
||||
};
|
||||
return statusMap[taskId]?.[step] ?? "pending";
|
||||
}
|
||||
|
||||
function isEdgeActive(fromId: string, toId: string, step: number): boolean {
|
||||
const fromStatus = getTaskStatus(fromId, step);
|
||||
const toStatus = getTaskStatus(toId, step);
|
||||
return (
|
||||
fromStatus === "completed" &&
|
||||
(toStatus === "in_progress" || toStatus === "completed")
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusColor(status: TaskStatus) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return {
|
||||
fill: "#e2e8f0",
|
||||
darkFill: "#27272a",
|
||||
stroke: "#cbd5e1",
|
||||
darkStroke: "#3f3f46",
|
||||
text: "#475569",
|
||||
darkText: "#d4d4d8",
|
||||
};
|
||||
case "in_progress":
|
||||
return {
|
||||
fill: "#fef3c7",
|
||||
darkFill: "#451a0340",
|
||||
stroke: "#f59e0b",
|
||||
darkStroke: "#d97706",
|
||||
text: "#b45309",
|
||||
darkText: "#fbbf24",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
fill: "#d1fae5",
|
||||
darkFill: "#06402740",
|
||||
stroke: "#10b981",
|
||||
darkStroke: "#059669",
|
||||
text: "#047857",
|
||||
darkText: "#34d399",
|
||||
};
|
||||
case "blocked":
|
||||
return {
|
||||
fill: "#fecaca",
|
||||
darkFill: "#45050540",
|
||||
stroke: "#ef4444",
|
||||
darkStroke: "#dc2626",
|
||||
text: "#dc2626",
|
||||
darkText: "#f87171",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "pending";
|
||||
case "in_progress":
|
||||
return "in_progress";
|
||||
case "completed":
|
||||
return "done";
|
||||
case "blocked":
|
||||
return "blocked";
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurvePath(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number
|
||||
): string {
|
||||
const midX = (x1 + x2) / 2;
|
||||
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
export default function TaskSystem({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
|
||||
|
||||
const isDark = useDarkMode();
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const edges = useMemo(() => {
|
||||
const result: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}[] = [];
|
||||
for (const task of TASKS) {
|
||||
for (const depId of task.deps) {
|
||||
const dep = TASKS.find((t) => t.id === depId);
|
||||
if (!dep) continue;
|
||||
result.push({
|
||||
fromId: dep.id,
|
||||
toId: task.id,
|
||||
x1: dep.x + NODE_W,
|
||||
y1: dep.y + NODE_H / 2,
|
||||
x2: task.x,
|
||||
y2: task.y + NODE_H / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Task Dependency Graph"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<svg viewBox="0 0 800 340" className="w-full" aria-label="Task DAG">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowGray"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowGreen"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#10b981" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowRed"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ef4444" />
|
||||
</marker>
|
||||
<filter id="glowAmber" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feFlood floodColor="#f59e0b" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter
|
||||
id="glowGreen"
|
||||
x="-30%"
|
||||
y="-30%"
|
||||
width="160%"
|
||||
height="160%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#10b981" floodOpacity="0.3" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Dependency edges */}
|
||||
{edges.map(({ fromId, toId, x1, y1, x2, y2 }) => {
|
||||
const active = isEdgeActive(fromId, toId, currentStep);
|
||||
const toStatus = getTaskStatus(toId, currentStep);
|
||||
const isBlocked = toStatus === "blocked";
|
||||
let markerEnd = "url(#arrowGray)";
|
||||
let strokeColor = palette.arrowFill;
|
||||
if (active) {
|
||||
markerEnd = "url(#arrowGreen)";
|
||||
strokeColor = "#10b981";
|
||||
} else if (isBlocked) {
|
||||
markerEnd = "url(#arrowRed)";
|
||||
strokeColor = "#ef4444";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.path
|
||||
key={`${fromId}-${toId}`}
|
||||
d={buildCurvePath(x1, y1, x2, y2)}
|
||||
fill="none"
|
||||
markerEnd={markerEnd}
|
||||
animate={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth: active ? 2.5 : 1.5,
|
||||
strokeDasharray: isBlocked ? "6 4" : "none",
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Task nodes */}
|
||||
{TASKS.map((task) => {
|
||||
const status = getTaskStatus(task.id, currentStep);
|
||||
const colors = getStatusColor(status);
|
||||
const statusLabel = getStatusLabel(status);
|
||||
const isActive = status === "in_progress";
|
||||
const isComplete = status === "completed";
|
||||
|
||||
let filterAttr: string | undefined;
|
||||
if (isActive) filterAttr = "url(#glowAmber)";
|
||||
else if (isComplete) filterAttr = "url(#glowGreen)";
|
||||
|
||||
return (
|
||||
<g key={task.id} filter={filterAttr}>
|
||||
<motion.rect
|
||||
x={task.x}
|
||||
y={task.y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={8}
|
||||
animate={{
|
||||
fill: isDark ? colors.darkFill : colors.fill,
|
||||
stroke: isDark ? colors.darkStroke : colors.stroke,
|
||||
}}
|
||||
strokeWidth={isActive ? 2 : 1.5}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<text
|
||||
x={task.x + NODE_W / 2}
|
||||
y={task.y + 20}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill={isDark ? colors.darkText : colors.text}
|
||||
>
|
||||
{task.label}
|
||||
</text>
|
||||
<text
|
||||
x={task.x + NODE_W / 2}
|
||||
y={task.y + 38}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? colors.darkText : colors.text}
|
||||
opacity={0.8}
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Blocked annotation for T4 at step 4 */}
|
||||
{currentStep === 4 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={445}
|
||||
y={118}
|
||||
width={170}
|
||||
height={22}
|
||||
rx={4}
|
||||
fill={isDark ? "#451a03" : "#fef2f2"}
|
||||
stroke={isDark ? "#dc2626" : "#fca5a5"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={530}
|
||||
y={132}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? "#f87171" : "#dc2626"}
|
||||
>
|
||||
Blocked: waiting on T3
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* File persistence indicator */}
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-800/60">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
.tasks/tasks.json
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
Persisted to disk -- survives context compaction
|
||||
</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-auto h-2 w-2 rounded-full bg-emerald-500"
|
||||
animate={{ opacity: [1, 0.3, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-zinc-300 dark:bg-zinc-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
pending
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-amber-400 dark:bg-amber-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
in_progress
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-emerald-400 dark:bg-emerald-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-red-400 dark:bg-red-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
blocked
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.description}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
interface StepInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const STEP_INFO: StepInfo[] = [
|
||||
{
|
||||
title: "Three Lanes",
|
||||
description:
|
||||
"The agent has a main thread and can spawn daemon background threads for parallel work.",
|
||||
},
|
||||
{
|
||||
title: "Main Thread Working",
|
||||
description:
|
||||
"The main agent loop runs as usual, processing user requests.",
|
||||
},
|
||||
{
|
||||
title: "Spawn Background",
|
||||
description:
|
||||
"Background tasks run as daemon threads. The main loop doesn't wait for them.",
|
||||
},
|
||||
{
|
||||
title: "Multiple Backgrounds",
|
||||
description: "Multiple background tasks can run concurrently.",
|
||||
},
|
||||
{
|
||||
title: "Task Completes",
|
||||
description:
|
||||
"Background task finishes. Its result goes to the notification queue.",
|
||||
},
|
||||
{
|
||||
title: "Queue Fills",
|
||||
description:
|
||||
"Results accumulate in the queue, invisible to the model during this turn.",
|
||||
},
|
||||
{
|
||||
title: "Drain Queue",
|
||||
description:
|
||||
"Just before the next LLM call, all queued notifications are injected as tool_results. Non-blocking, async.",
|
||||
},
|
||||
];
|
||||
|
||||
const LANE_Y = {
|
||||
main: 60,
|
||||
bg1: 140,
|
||||
bg2: 220,
|
||||
} as const;
|
||||
|
||||
const LANE_HEIGHT = 44;
|
||||
const TIMELINE_LEFT = 160;
|
||||
const TIMELINE_RIGHT = 720;
|
||||
const TIMELINE_WIDTH = TIMELINE_RIGHT - TIMELINE_LEFT;
|
||||
|
||||
const QUEUE_Y = 300;
|
||||
|
||||
interface WorkBlock {
|
||||
lane: "main" | "bg1" | "bg2";
|
||||
startFraction: number;
|
||||
endFraction: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
appearsAtStep: number;
|
||||
completesAtStep?: number;
|
||||
}
|
||||
|
||||
const WORK_BLOCKS: WorkBlock[] = [
|
||||
{
|
||||
lane: "main",
|
||||
startFraction: 0,
|
||||
endFraction: 1,
|
||||
color: "#8b5cf6",
|
||||
label: "Main agent loop",
|
||||
appearsAtStep: 1,
|
||||
},
|
||||
{
|
||||
lane: "bg1",
|
||||
startFraction: 0.18,
|
||||
endFraction: 0.75,
|
||||
color: "#10b981",
|
||||
label: "Run tests",
|
||||
appearsAtStep: 2,
|
||||
completesAtStep: 5,
|
||||
},
|
||||
{
|
||||
lane: "bg2",
|
||||
startFraction: 0.35,
|
||||
endFraction: 0.58,
|
||||
color: "#3b82f6",
|
||||
label: "Lint code",
|
||||
appearsAtStep: 3,
|
||||
completesAtStep: 4,
|
||||
},
|
||||
];
|
||||
|
||||
interface ForkArrow {
|
||||
fromFraction: number;
|
||||
toLane: "bg1" | "bg2";
|
||||
appearsAtStep: number;
|
||||
}
|
||||
|
||||
const FORK_ARROWS: ForkArrow[] = [
|
||||
{ fromFraction: 0.18, toLane: "bg1", appearsAtStep: 2 },
|
||||
{ fromFraction: 0.35, toLane: "bg2", appearsAtStep: 3 },
|
||||
];
|
||||
|
||||
interface QueueCard {
|
||||
id: string;
|
||||
label: string;
|
||||
appearsAtStep: number;
|
||||
drainsAtStep: number;
|
||||
}
|
||||
|
||||
const QUEUE_CARDS: QueueCard[] = [
|
||||
{
|
||||
id: "lint-result",
|
||||
label: "Lint: 0 errors",
|
||||
appearsAtStep: 4,
|
||||
drainsAtStep: 6,
|
||||
},
|
||||
{
|
||||
id: "test-result",
|
||||
label: "Tests: 42 passed",
|
||||
appearsAtStep: 5,
|
||||
drainsAtStep: 6,
|
||||
},
|
||||
];
|
||||
|
||||
function fractionToX(fraction: number): number {
|
||||
return TIMELINE_LEFT + fraction * TIMELINE_WIDTH;
|
||||
}
|
||||
|
||||
function getBlockEndFraction(block: WorkBlock, step: number): number {
|
||||
if (step < block.appearsAtStep) return block.startFraction;
|
||||
if (block.completesAtStep !== undefined && step >= block.completesAtStep) {
|
||||
return block.endFraction;
|
||||
}
|
||||
const growthSteps = (block.completesAtStep ?? 6) - block.appearsAtStep;
|
||||
const stepsElapsed = step - block.appearsAtStep;
|
||||
const progress = Math.min(stepsElapsed / growthSteps, 1);
|
||||
const range = block.endFraction - block.startFraction;
|
||||
return block.startFraction + range * progress;
|
||||
}
|
||||
|
||||
export default function BackgroundTasks({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
|
||||
|
||||
const isDark = useDarkMode();
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
|
||||
const llmCallFraction = 0.82;
|
||||
const showLlmMarker = currentStep >= 5;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Background Task Lanes"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<svg viewBox="0 0 780 380" className="w-full" aria-label="Background task lanes">
|
||||
<defs>
|
||||
<marker
|
||||
id="forkArrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
<marker
|
||||
id="drainArrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#f59e0b" />
|
||||
</marker>
|
||||
<filter id="blockGlow" x="-10%" y="-20%" width="120%" height="140%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feFlood floodColor="#8b5cf6" floodOpacity="0.2" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Timeline axis */}
|
||||
<line
|
||||
x1={TIMELINE_LEFT}
|
||||
y1={30}
|
||||
x2={TIMELINE_RIGHT}
|
||||
y2={30}
|
||||
stroke={palette.labelFill}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 3"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<text
|
||||
x={TIMELINE_LEFT}
|
||||
y={22}
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
t=0
|
||||
</text>
|
||||
<text
|
||||
x={TIMELINE_RIGHT}
|
||||
y={22}
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
textAnchor="end"
|
||||
>
|
||||
time
|
||||
</text>
|
||||
|
||||
{/* Lane backgrounds and labels */}
|
||||
{(
|
||||
[
|
||||
{ key: "main", y: LANE_Y.main, label: "Main Thread" },
|
||||
{ key: "bg1", y: LANE_Y.bg1, label: "Background 1" },
|
||||
{ key: "bg2", y: LANE_Y.bg2, label: "Background 2" },
|
||||
] as const
|
||||
).map(({ key, y, label }) => (
|
||||
<g key={key}>
|
||||
<rect
|
||||
x={TIMELINE_LEFT}
|
||||
y={y}
|
||||
width={TIMELINE_WIDTH}
|
||||
height={LANE_HEIGHT}
|
||||
rx={6}
|
||||
fill="none"
|
||||
stroke={palette.nodeStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 2"
|
||||
opacity={0.6}
|
||||
/>
|
||||
<text
|
||||
x={TIMELINE_LEFT - 10}
|
||||
y={y + LANE_HEIGHT / 2 + 1}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Work blocks */}
|
||||
{WORK_BLOCKS.map((block) => {
|
||||
if (currentStep < block.appearsAtStep) return null;
|
||||
|
||||
const startX = fractionToX(block.startFraction);
|
||||
const endFraction = getBlockEndFraction(block, currentStep);
|
||||
const endX = fractionToX(endFraction);
|
||||
const width = Math.max(endX - startX, 4);
|
||||
const y = LANE_Y[block.lane];
|
||||
const isComplete =
|
||||
block.completesAtStep !== undefined &&
|
||||
currentStep >= block.completesAtStep;
|
||||
|
||||
return (
|
||||
<motion.g key={`${block.lane}-block`}>
|
||||
<motion.rect
|
||||
x={startX}
|
||||
y={y + 4}
|
||||
height={LANE_HEIGHT - 8}
|
||||
rx={5}
|
||||
initial={{ width: 4 }}
|
||||
animate={{
|
||||
width,
|
||||
opacity: isComplete ? 0.7 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
fill={block.color}
|
||||
filter={
|
||||
!isComplete && block.lane === "main"
|
||||
? "url(#blockGlow)"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{width > 60 && block.label && (
|
||||
<motion.text
|
||||
x={startX + width / 2}
|
||||
y={y + LANE_HEIGHT / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="10"
|
||||
fontWeight="500"
|
||||
fill="white"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
{block.label}
|
||||
</motion.text>
|
||||
)}
|
||||
{isComplete && (
|
||||
<motion.text
|
||||
x={endX + 6}
|
||||
y={y + LANE_HEIGHT / 2 + 1}
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill="#10b981"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
done
|
||||
</motion.text>
|
||||
)}
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Fork arrows from main to background lanes */}
|
||||
{FORK_ARROWS.map((arrow) => {
|
||||
if (currentStep < arrow.appearsAtStep) return null;
|
||||
const x = fractionToX(arrow.fromFraction);
|
||||
const fromY = LANE_Y.main + LANE_HEIGHT;
|
||||
const toY = LANE_Y[arrow.toLane];
|
||||
|
||||
return (
|
||||
<motion.line
|
||||
key={`fork-${arrow.toLane}`}
|
||||
x1={x}
|
||||
y1={fromY}
|
||||
x2={x + 20}
|
||||
y2={toY}
|
||||
stroke={palette.arrowFill}
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#forkArrow)"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* LLM API call marker */}
|
||||
{showLlmMarker && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<line
|
||||
x1={fractionToX(llmCallFraction)}
|
||||
y1={LANE_Y.main}
|
||||
x2={fractionToX(llmCallFraction)}
|
||||
y2={LANE_Y.main + LANE_HEIGHT}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
<rect
|
||||
x={fractionToX(llmCallFraction) - 36}
|
||||
y={LANE_Y.main - 16}
|
||||
width={72}
|
||||
height={16}
|
||||
rx={3}
|
||||
fill="#f59e0b"
|
||||
/>
|
||||
<text
|
||||
x={fractionToX(llmCallFraction)}
|
||||
y={LANE_Y.main - 6}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="8"
|
||||
fontWeight="600"
|
||||
fill="white"
|
||||
>
|
||||
LLM API call
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Notification queue area */}
|
||||
<rect
|
||||
x={TIMELINE_LEFT}
|
||||
y={QUEUE_Y}
|
||||
width={TIMELINE_WIDTH}
|
||||
height={54}
|
||||
rx={8}
|
||||
fill="none"
|
||||
stroke={palette.nodeStroke}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={TIMELINE_LEFT - 10}
|
||||
y={QUEUE_Y + 18}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fontWeight="600"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
Notification
|
||||
</text>
|
||||
<text
|
||||
x={TIMELINE_LEFT - 10}
|
||||
y={QUEUE_Y + 32}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fontWeight="600"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
Queue
|
||||
</text>
|
||||
|
||||
{/* Queue cards */}
|
||||
<AnimatePresence>
|
||||
{QUEUE_CARDS.map((card, idx) => {
|
||||
if (currentStep < card.appearsAtStep) return null;
|
||||
const isDraining = currentStep >= card.drainsAtStep;
|
||||
const cardX = TIMELINE_LEFT + 20 + idx * 150;
|
||||
const cardY = QUEUE_Y + 10;
|
||||
const drainTargetY = LANE_Y.main + LANE_HEIGHT / 2 - 12;
|
||||
const drainTargetX = fractionToX(llmCallFraction) + 10 + idx * 15;
|
||||
|
||||
if (isDraining) {
|
||||
return (
|
||||
<motion.g
|
||||
key={`card-${card.id}-drain`}
|
||||
initial={{ x: cardX, y: cardY, opacity: 1 }}
|
||||
animate={{
|
||||
x: drainTargetX,
|
||||
y: drainTargetY,
|
||||
opacity: [1, 1, 0],
|
||||
}}
|
||||
transition={{ duration: 0.8, ease: "easeInOut" }}
|
||||
>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={130}
|
||||
height={34}
|
||||
rx={5}
|
||||
fill={isDark ? "#451a0340" : "#fef3c7"}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={65}
|
||||
y={13}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
fill={isDark ? "#fbbf24" : "#b45309"}
|
||||
>
|
||||
tool_result
|
||||
</text>
|
||||
<text
|
||||
x={65}
|
||||
y={26}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? "#f59e0b" : "#92400e"}
|
||||
>
|
||||
{card.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.g
|
||||
key={`card-${card.id}`}
|
||||
initial={{ y: cardY - 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
>
|
||||
<rect
|
||||
x={cardX}
|
||||
y={cardY}
|
||||
width={130}
|
||||
height={34}
|
||||
rx={5}
|
||||
fill={isDark ? "#06402740" : "#d1fae5"}
|
||||
stroke="#10b981"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={cardX + 65}
|
||||
y={cardY + 13}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
fill={isDark ? "#34d399" : "#047857"}
|
||||
>
|
||||
tool_result
|
||||
</text>
|
||||
<text
|
||||
x={cardX + 65}
|
||||
y={cardY + 26}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? "#10b981" : "#065f46"}
|
||||
>
|
||||
{card.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Drain arrows from queue to main thread at step 6 */}
|
||||
{currentStep >= 6 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<motion.line
|
||||
x1={fractionToX(llmCallFraction) + 20}
|
||||
y1={QUEUE_Y}
|
||||
x2={fractionToX(llmCallFraction) + 20}
|
||||
y2={LANE_Y.main + LANE_HEIGHT + 4}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1.5}
|
||||
markerEnd="url(#drainArrow)"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Empty queue label when drained */}
|
||||
{currentStep >= 6 && (
|
||||
<motion.text
|
||||
x={TIMELINE_LEFT + TIMELINE_WIDTH / 2}
|
||||
y={QUEUE_Y + 30}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="10"
|
||||
fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
queue drained -- injected into next LLM call
|
||||
</motion.text>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded" style={{ background: "#8b5cf6" }} />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
Main thread
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded" style={{ background: "#10b981" }} />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
Background 1
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded" style={{ background: "#3b82f6" }} />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
Background 2
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded" style={{ background: "#f59e0b" }} />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
LLM boundary
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.description}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
// -- Layout constants --
|
||||
const SVG_W = 560;
|
||||
const SVG_H = 340;
|
||||
const AGENT_R = 40;
|
||||
|
||||
// Agent positions: inverted triangle (Lead top-center, Coder bottom-left, Reviewer bottom-right)
|
||||
const AGENTS = [
|
||||
{ id: "lead", label: "Lead", cx: SVG_W / 2, cy: 70, inbox: "lead.jsonl" },
|
||||
{ id: "coder", label: "Coder", cx: 140, cy: 230, inbox: "coder.jsonl" },
|
||||
{ id: "reviewer", label: "Reviewer", cx: SVG_W - 140, cy: 230, inbox: "reviewer.jsonl" },
|
||||
] as const;
|
||||
|
||||
// Inbox tray dimensions, positioned below each agent circle
|
||||
const TRAY_W = 72;
|
||||
const TRAY_H = 22;
|
||||
const TRAY_OFFSET_Y = AGENT_R + 14;
|
||||
|
||||
// Message block dimensions
|
||||
const MSG_W = 60;
|
||||
const MSG_H = 20;
|
||||
|
||||
function agentById(id: string) {
|
||||
return AGENTS.find((a) => a.id === id)!;
|
||||
}
|
||||
|
||||
function trayCenter(id: string) {
|
||||
const a = agentById(id);
|
||||
return { x: a.cx, y: a.cy + TRAY_OFFSET_Y + TRAY_H / 2 };
|
||||
}
|
||||
|
||||
// Step configuration
|
||||
const STEPS = [
|
||||
{ title: "The Team", desc: "Teams use a leader-worker pattern. Each teammate has a file-based mailbox inbox." },
|
||||
{ title: "Lead Assigns Work", desc: "Communication is async: write a message to the recipient's .jsonl inbox file." },
|
||||
{ title: "Read Inbox", desc: "Teammates poll their inbox before each LLM call. New messages become context." },
|
||||
{ title: "Independent Work", desc: "Each teammate runs its own agent loop independently." },
|
||||
{ title: "Pass Result", desc: "Results flow through the same mailbox mechanism. All communication is via files." },
|
||||
{ title: "Feedback Loop", desc: "The mailbox pattern supports any communication topology: linear, broadcast, round-robin." },
|
||||
{ title: "File-Based Coordination", desc: "No shared memory, no locks. All coordination through append-only files. Simple, robust, debuggable." },
|
||||
];
|
||||
|
||||
// Helper: determine which agent glows at each step
|
||||
function agentGlows(agentId: string, step: number): boolean {
|
||||
if (step === 1 && agentId === "lead") return true;
|
||||
if (step === 2 && agentId === "coder") return true;
|
||||
if (step === 3 && agentId === "coder") return true;
|
||||
if (step === 4 && agentId === "coder") return true;
|
||||
if (step === 5 && agentId === "reviewer") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper: determine which inbox tray has a message sitting in it
|
||||
function trayHasMessage(agentId: string, step: number): boolean {
|
||||
if (step === 2 && agentId === "coder") return true;
|
||||
if (step === 4 && agentId === "reviewer") return false;
|
||||
if (step === 5 && agentId === "reviewer") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Animated message that travels from one point to another
|
||||
function TravelingMessage({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
label,
|
||||
delay = 0,
|
||||
}: {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
label: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 1, 0.8],
|
||||
x: [fromX - MSG_W / 2, fromX - MSG_W / 2, toX - MSG_W / 2, toX - MSG_W / 2],
|
||||
y: [fromY - MSG_H / 2, fromY - MSG_H / 2, toY - MSG_H / 2, toY - MSG_H / 2],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
delay,
|
||||
times: [0, 0.1, 0.7, 1],
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<rect width={MSG_W} height={MSG_H} rx={4} fill="#f59e0b" />
|
||||
<text
|
||||
x={MSG_W / 2}
|
||||
y={MSG_H / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
// Faded trace line between two agents
|
||||
function TraceLine({ from, to, strokeColor }: { from: string; to: string; strokeColor: string }) {
|
||||
const f = trayCenter(from);
|
||||
const t = trayCenter(to);
|
||||
return (
|
||||
<motion.line
|
||||
x1={f.x}
|
||||
y1={f.y}
|
||||
x2={t.x}
|
||||
y2={t.y}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentTeams({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Agent Team Mailboxes"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* SVG visualization */}
|
||||
<div className="flex-1">
|
||||
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
|
||||
<defs>
|
||||
<filter id="agent-glow">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Step 6: trace lines */}
|
||||
{step === 6 && (
|
||||
<>
|
||||
<TraceLine from="lead" to="coder" strokeColor={palette.edgeStroke} />
|
||||
<TraceLine from="coder" to="reviewer" strokeColor={palette.edgeStroke} />
|
||||
<TraceLine from="reviewer" to="lead" strokeColor={palette.edgeStroke} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agent nodes */}
|
||||
{AGENTS.map((agent) => {
|
||||
const glowing = agentGlows(agent.id, step);
|
||||
const pulsing = step === 3 && agent.id === "coder";
|
||||
|
||||
return (
|
||||
<g key={agent.id}>
|
||||
{/* Agent circle */}
|
||||
<motion.circle
|
||||
cx={agent.cx}
|
||||
cy={agent.cy}
|
||||
r={AGENT_R}
|
||||
fill={glowing ? "#3b82f6" : palette.edgeStroke}
|
||||
stroke={glowing ? "#60a5fa" : palette.labelFill}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
scale: pulsing ? [1, 1.08, 1] : 1,
|
||||
fill: glowing ? "#3b82f6" : palette.edgeStroke,
|
||||
}}
|
||||
transition={
|
||||
pulsing
|
||||
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
|
||||
: { duration: 0.4 }
|
||||
}
|
||||
filter={glowing ? "url(#agent-glow)" : undefined}
|
||||
/>
|
||||
{/* Agent label */}
|
||||
<text
|
||||
x={agent.cx}
|
||||
y={agent.cy + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
>
|
||||
{agent.label}
|
||||
</text>
|
||||
|
||||
{/* Inbox tray (file icon style) */}
|
||||
<rect
|
||||
x={agent.cx - TRAY_W / 2}
|
||||
y={agent.cy + TRAY_OFFSET_Y}
|
||||
width={TRAY_W}
|
||||
height={TRAY_H}
|
||||
rx={3}
|
||||
fill={trayHasMessage(agent.id, step) ? "#fef3c7" : palette.nodeFill}
|
||||
stroke={trayHasMessage(agent.id, step) ? "#f59e0b" : palette.nodeStroke}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={agent.cx}
|
||||
y={agent.cy + TRAY_OFFSET_Y + TRAY_H / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
{agent.inbox}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Step 0: team config card */}
|
||||
{step === 0 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<rect x={12} y={12} width={100} height={44} rx={4} fill="#f0f9ff" stroke="#bae6fd" strokeWidth={1} />
|
||||
<text x={20} y={28} fontSize={7} fontFamily="monospace" fill="#0284c7" fontWeight={600}>
|
||||
team.config
|
||||
</text>
|
||||
<text x={20} y={40} fontSize={6} fontFamily="monospace" fill="#0369a1">
|
||||
workers: [coder, reviewer]
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Step 1: message from Lead to Coder inbox */}
|
||||
<AnimatePresence>
|
||||
{step === 1 && (
|
||||
<TravelingMessage
|
||||
key="msg-lead-coder"
|
||||
fromX={agentById("lead").cx}
|
||||
fromY={agentById("lead").cy + AGENT_R}
|
||||
toX={agentById("coder").cx}
|
||||
toY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="task:login"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 2: message from Coder inbox to Coder circle */}
|
||||
<AnimatePresence>
|
||||
{step === 2 && (
|
||||
<TravelingMessage
|
||||
key="msg-inbox-coder"
|
||||
fromX={agentById("coder").cx}
|
||||
fromY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
toX={agentById("coder").cx}
|
||||
toY={agentById("coder").cy}
|
||||
label="task:login"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 3: Coder working, result appears */}
|
||||
<AnimatePresence>
|
||||
{step === 3 && (
|
||||
<motion.g
|
||||
key="result-msg"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={agentById("coder").cx + AGENT_R + 8}
|
||||
y={agentById("coder").cy - MSG_H / 2}
|
||||
width={MSG_W + 10}
|
||||
height={MSG_H}
|
||||
rx={4}
|
||||
fill="#10b981"
|
||||
/>
|
||||
<text
|
||||
x={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
|
||||
y={agentById("coder").cy + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
result:done
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 4: Coder result message travels to Reviewer inbox */}
|
||||
<AnimatePresence>
|
||||
{step === 4 && (
|
||||
<TravelingMessage
|
||||
key="msg-coder-reviewer"
|
||||
fromX={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
|
||||
fromY={agentById("coder").cy}
|
||||
toX={agentById("reviewer").cx}
|
||||
toY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="result:done"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 5: Reviewer reads inbox, sends feedback to Lead */}
|
||||
<AnimatePresence>
|
||||
{step === 5 && (
|
||||
<>
|
||||
<TravelingMessage
|
||||
key="msg-reviewer-read"
|
||||
fromX={agentById("reviewer").cx}
|
||||
fromY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
toX={agentById("reviewer").cx}
|
||||
toY={agentById("reviewer").cy}
|
||||
label="result:done"
|
||||
delay={0}
|
||||
/>
|
||||
<TravelingMessage
|
||||
key="msg-reviewer-lead"
|
||||
fromX={agentById("reviewer").cx}
|
||||
fromY={agentById("reviewer").cy}
|
||||
toX={agentById("lead").cx}
|
||||
toY={agentById("lead").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="feedback"
|
||||
delay={1.0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 6: filesystem tree */}
|
||||
{step === 6 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<rect x={SVG_W / 2 - 110} y={SVG_H - 80} width={220} height={68} rx={6} fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1} />
|
||||
<text x={SVG_W / 2 - 96} y={SVG_H - 60} fontSize={8} fontFamily="monospace" fill={palette.labelFill}>
|
||||
.claude/teams/project/
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 48} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
lead.jsonl
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 36} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
coder.jsonl
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 24} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
reviewer.jsonl
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={STEPS[step].title}
|
||||
stepDescription={STEPS[step].desc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
type Protocol = "shutdown" | "plan";
|
||||
|
||||
// -- Layout constants for the sequence diagram --
|
||||
const SVG_W = 560;
|
||||
const SVG_H = 360;
|
||||
const LIFELINE_LEFT_X = 140;
|
||||
const LIFELINE_RIGHT_X = 420;
|
||||
const LIFELINE_TOP = 60;
|
||||
const LIFELINE_BOTTOM = 330;
|
||||
const ACTIVATION_W = 12;
|
||||
const ARROW_Y_START = 110;
|
||||
const ARROW_Y_GAP = 70;
|
||||
|
||||
// Request ID shown on message tags
|
||||
const REQUEST_ID = "req_abc";
|
||||
|
||||
// -- Shutdown protocol step definitions --
|
||||
const SHUTDOWN_STEPS = [
|
||||
{ title: "Structured Protocols", desc: "Protocols define structured message exchanges with correlated request IDs." },
|
||||
{ title: "Shutdown Request", desc: "The leader initiates shutdown. The request_id links the request to its response." },
|
||||
{ title: "Teammate Decides", desc: "The teammate can accept or reject. It's not a forced kill -- it's a polite request." },
|
||||
{ title: "Approved", desc: "Same request_id in the response. Teammate exits cleanly." },
|
||||
];
|
||||
|
||||
// -- Plan approval protocol step definitions --
|
||||
const PLAN_STEPS = [
|
||||
{ title: "Plan Approval", desc: "Teammates in plan_mode must get approval before implementing changes." },
|
||||
{ title: "Submit Plan", desc: "The teammate designs a plan and sends it to the leader for review." },
|
||||
{ title: "Leader Reviews", desc: "Leader reviews and approves or rejects with feedback. Same request-response pattern." },
|
||||
];
|
||||
|
||||
// Horizontal arrow between lifelines
|
||||
function SequenceArrow({
|
||||
y,
|
||||
direction,
|
||||
label,
|
||||
tagLabel,
|
||||
color,
|
||||
tagBg,
|
||||
tagStroke,
|
||||
tagText,
|
||||
}: {
|
||||
y: number;
|
||||
direction: "right" | "left";
|
||||
label: string;
|
||||
tagLabel?: string;
|
||||
color: string;
|
||||
tagBg?: string;
|
||||
tagStroke?: string;
|
||||
tagText?: string;
|
||||
}) {
|
||||
const fromX = direction === "right" ? LIFELINE_LEFT_X + ACTIVATION_W / 2 : LIFELINE_RIGHT_X - ACTIVATION_W / 2;
|
||||
const toX = direction === "right" ? LIFELINE_RIGHT_X - ACTIVATION_W / 2 : LIFELINE_LEFT_X + ACTIVATION_W / 2;
|
||||
const arrowTip = direction === "right" ? toX - 6 : toX + 6;
|
||||
const labelX = (fromX + toX) / 2;
|
||||
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Arrow line */}
|
||||
<line
|
||||
x1={fromX}
|
||||
y1={y}
|
||||
x2={toX}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points={
|
||||
direction === "right"
|
||||
? `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
|
||||
: `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
|
||||
}
|
||||
fill={color}
|
||||
/>
|
||||
{/* Message label */}
|
||||
<text
|
||||
x={labelX}
|
||||
y={y - 10}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
fontFamily="monospace"
|
||||
fontWeight={600}
|
||||
fill={color}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
{/* Request ID tag */}
|
||||
{tagLabel && (
|
||||
<g>
|
||||
<rect
|
||||
x={labelX - 36}
|
||||
y={y + 4}
|
||||
width={72}
|
||||
height={16}
|
||||
rx={3}
|
||||
fill={tagBg || "#f5f3ff"}
|
||||
stroke={tagStroke || "#c4b5fd"}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={y + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={6}
|
||||
fontFamily="monospace"
|
||||
fill={tagText || "#7c3aed"}
|
||||
>
|
||||
{tagLabel}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision diamond on a lifeline
|
||||
function DecisionBox({ x, y }: { x: number; y: number }) {
|
||||
const size = 14;
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<polygon
|
||||
points={`${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`}
|
||||
fill="#fef3c7"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text x={x} y={y + 1} textAnchor="middle" dominantBaseline="middle" fontSize={7} fontWeight={700} fill="#92400e">
|
||||
?
|
||||
</text>
|
||||
<text x={x + size + 6} y={y - 4} fontSize={6} fontFamily="monospace" fill="#10b981">
|
||||
approve
|
||||
</text>
|
||||
<text x={x + size + 6} y={y + 6} fontSize={6} fontFamily="monospace" fill="#ef4444">
|
||||
reject
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
// Activation bar on a lifeline
|
||||
function ActivationBar({
|
||||
x,
|
||||
yStart,
|
||||
yEnd,
|
||||
color,
|
||||
}: {
|
||||
x: number;
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.rect
|
||||
x={x - ACTIVATION_W / 2}
|
||||
y={yStart}
|
||||
width={ACTIVATION_W}
|
||||
height={yEnd - yStart}
|
||||
rx={2}
|
||||
fill={color}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamProtocols({ title }: { title?: string }) {
|
||||
const [protocol, setProtocol] = useState<Protocol>("shutdown");
|
||||
|
||||
const totalSteps = protocol === "shutdown" ? SHUTDOWN_STEPS.length : PLAN_STEPS.length;
|
||||
const steps = protocol === "shutdown" ? SHUTDOWN_STEPS : PLAN_STEPS;
|
||||
|
||||
const vis = useSteppedVisualization({ totalSteps, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const switchProtocol = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
vis.reset();
|
||||
};
|
||||
|
||||
const leftLabel = protocol === "shutdown" ? "Leader" : "Leader";
|
||||
const rightLabel = protocol === "shutdown" ? "Teammate" : "Teammate";
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "FSM Team Protocols"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
{/* Protocol toggle */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => switchProtocol("shutdown")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
protocol === "shutdown"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Shutdown Protocol
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchProtocol("plan")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
protocol === "plan"
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Plan Approval Protocol
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sequence diagram SVG */}
|
||||
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
|
||||
<defs>
|
||||
<marker
|
||||
id="seq-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Lifeline headers */}
|
||||
<rect x={LIFELINE_LEFT_X - 40} y={20} width={80} height={28} rx={6} fill="#3b82f6" />
|
||||
<text x={LIFELINE_LEFT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
{leftLabel}
|
||||
</text>
|
||||
|
||||
<rect x={LIFELINE_RIGHT_X - 40} y={20} width={80} height={28} rx={6} fill="#8b5cf6" />
|
||||
<text x={LIFELINE_RIGHT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
{rightLabel}
|
||||
</text>
|
||||
|
||||
{/* Lifeline dashed lines */}
|
||||
<line
|
||||
x1={LIFELINE_LEFT_X}
|
||||
y1={LIFELINE_TOP}
|
||||
x2={LIFELINE_LEFT_X}
|
||||
y2={LIFELINE_BOTTOM}
|
||||
stroke={palette.edgeStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 4"
|
||||
/>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X}
|
||||
y1={LIFELINE_TOP}
|
||||
x2={LIFELINE_RIGHT_X}
|
||||
y2={LIFELINE_BOTTOM}
|
||||
stroke={palette.edgeStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 4"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{protocol === "shutdown" && (
|
||||
<g key="shutdown">
|
||||
{/* Activation bars appear as needed */}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_LEFT_X}
|
||||
yStart={ARROW_Y_START - 10}
|
||||
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 20 : ARROW_Y_START + 30}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
)}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_RIGHT_X}
|
||||
yStart={ARROW_Y_START - 5}
|
||||
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 20}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: shutdown_request arrow (Leader -> Teammate) */}
|
||||
{step >= 1 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START}
|
||||
direction="right"
|
||||
label="shutdown_request"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#3b82f6"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: decision box on teammate lifeline */}
|
||||
{step >= 2 && (
|
||||
<DecisionBox
|
||||
x={LIFELINE_RIGHT_X + 50}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: shutdown_response arrow (Teammate -> Leader) */}
|
||||
{step >= 3 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2}
|
||||
direction="left"
|
||||
label="shutdown_response { approve: true }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#10b981"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: exit annotation */}
|
||||
{step >= 3 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X - 10}
|
||||
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
|
||||
x2={LIFELINE_RIGHT_X + 10}
|
||||
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X + 10}
|
||||
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
|
||||
x2={LIFELINE_RIGHT_X - 10}
|
||||
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={LIFELINE_RIGHT_X + 24}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 32}
|
||||
fontSize={8}
|
||||
fill="#ef4444"
|
||||
fontWeight={600}
|
||||
>
|
||||
exit
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{protocol === "plan" && (
|
||||
<g key="plan">
|
||||
{/* Activation bars */}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_RIGHT_X}
|
||||
yStart={ARROW_Y_START - 10}
|
||||
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + 30}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
)}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_LEFT_X}
|
||||
yStart={ARROW_Y_START - 5}
|
||||
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 10}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: plan submission arrow (Teammate -> Leader) */}
|
||||
{step >= 1 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START}
|
||||
direction="left"
|
||||
label="exit_plan_mode { plan }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#8b5cf6"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: plan content box */}
|
||||
{step >= 1 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={20}
|
||||
y={ARROW_Y_START + 20}
|
||||
width={95}
|
||||
height={50}
|
||||
rx={4}
|
||||
fill={palette.bgSubtle}
|
||||
stroke={palette.nodeStroke}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text x={28} y={ARROW_Y_START + 34} fontSize={6} fontFamily="monospace" fill={palette.nodeText} fontWeight={600}>
|
||||
Plan:
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 44} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
1. Add error handler
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 54} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
2. Update tests
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 64} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
3. Refactor module
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Step 2: approval response arrow (Leader -> Teammate) */}
|
||||
{step >= 2 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2}
|
||||
direction="right"
|
||||
label="plan_approval_response { approve: true }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#10b981"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: checkmark */}
|
||||
{step >= 2 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<circle cx={LIFELINE_RIGHT_X + 40} cy={ARROW_Y_START + ARROW_Y_GAP * 2} r={10} fill="#10b981" />
|
||||
<text
|
||||
x={LIFELINE_RIGHT_X + 40}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fill="white"
|
||||
fontWeight={700}
|
||||
>
|
||||
OK
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</svg>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={steps[step].title}
|
||||
stepDescription={steps[step].desc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
|
||||
// -- FSM states and their layout positions (diamond: idle top, poll right, claim bottom, work left) --
|
||||
type Phase = "idle" | "poll" | "claim" | "work";
|
||||
|
||||
const FSM_CX = 110;
|
||||
const FSM_CY = 110;
|
||||
const FSM_R = 65;
|
||||
const FSM_STATE_R = 22;
|
||||
|
||||
const FSM_STATES: { id: Phase; label: string; angle: number }[] = [
|
||||
{ id: "idle", label: "idle", angle: -Math.PI / 2 },
|
||||
{ id: "poll", label: "poll", angle: 0 },
|
||||
{ id: "claim", label: "claim", angle: Math.PI / 2 },
|
||||
{ id: "work", label: "work", angle: Math.PI },
|
||||
];
|
||||
|
||||
const FSM_TRANSITIONS: { from: Phase; to: Phase }[] = [
|
||||
{ from: "idle", to: "poll" },
|
||||
{ from: "poll", to: "claim" },
|
||||
{ from: "claim", to: "work" },
|
||||
{ from: "work", to: "idle" },
|
||||
];
|
||||
|
||||
function fsmPos(angle: number) {
|
||||
return { x: FSM_CX + FSM_R * Math.cos(angle), y: FSM_CY + FSM_R * Math.sin(angle) };
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<Phase, string> = {
|
||||
idle: "#a1a1aa",
|
||||
poll: "#f59e0b",
|
||||
claim: "#3b82f6",
|
||||
work: "#10b981",
|
||||
};
|
||||
|
||||
// -- Task board data --
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "unclaimed" | "active" | "complete";
|
||||
owner: string;
|
||||
}
|
||||
|
||||
const INITIAL_TASKS: TaskRow[] = [
|
||||
{ id: "T1", name: "Fix auth bug", status: "unclaimed", owner: "-" },
|
||||
{ id: "T2", name: "Add rate limiter", status: "unclaimed", owner: "-" },
|
||||
{ id: "T3", name: "Write tests", status: "unclaimed", owner: "-" },
|
||||
{ id: "T4", name: "Update API docs", status: "unclaimed", owner: "-" },
|
||||
];
|
||||
|
||||
// Agent positions around the task board (left panel)
|
||||
const BOARD_CX = 140;
|
||||
const BOARD_CY = 90;
|
||||
const AGENT_ORBIT = 85;
|
||||
const AGENT_R = 20;
|
||||
|
||||
const AGENT_ANGLES = [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6];
|
||||
|
||||
function agentPos(index: number) {
|
||||
const angle = AGENT_ANGLES[index];
|
||||
return { x: BOARD_CX + AGENT_ORBIT * Math.cos(angle), y: BOARD_CY + AGENT_ORBIT * Math.sin(angle) };
|
||||
}
|
||||
|
||||
// -- Step definitions --
|
||||
const STEPS = [
|
||||
{ title: "Self-Governing Agents", desc: "Autonomous agents need no coordinator. They govern themselves with an idle-poll-claim-work cycle." },
|
||||
{ title: "Idle Timer", desc: "Each idle agent counts rounds. A timeout triggers self-directed task polling." },
|
||||
{ title: "Poll Task Board", desc: "Timeout! The agent reads the task board looking for unclaimed work." },
|
||||
{ title: "Claim Task", desc: "The agent writes its name to the task record. Atomic, no conflicts." },
|
||||
{ title: "Work", desc: "The agent works on the claimed task using its own agent loop." },
|
||||
{ title: "Independent Polling", desc: "Multiple agents poll and claim independently. No central coordinator needed." },
|
||||
{ title: "Complete & Reset", desc: "Task done. Agent returns to idle. The cycle repeats." },
|
||||
{ title: "Self-Organization", desc: "Three agents, zero coordination overhead. Polling + timeout = emergent organization." },
|
||||
];
|
||||
|
||||
// Per-step state for each agent
|
||||
interface AgentState {
|
||||
phase: Phase;
|
||||
timerFill: number;
|
||||
color: string;
|
||||
taskClaim: string | null;
|
||||
}
|
||||
|
||||
function getAgentStates(step: number): AgentState[] {
|
||||
const idle: AgentState = { phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null };
|
||||
|
||||
switch (step) {
|
||||
case 0:
|
||||
return [
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0.6, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
{ phase: "poll", timerFill: 1.0, color: PHASE_COLORS.poll, taskClaim: null },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 3:
|
||||
return [
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T1" },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 4:
|
||||
return [
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T2" },
|
||||
{ ...idle },
|
||||
];
|
||||
case 6:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
|
||||
{ ...idle },
|
||||
];
|
||||
case 7:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T3" },
|
||||
];
|
||||
default:
|
||||
return [{ ...idle }, { ...idle }, { ...idle }];
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskStates(step: number): TaskRow[] {
|
||||
const tasks = INITIAL_TASKS.map((t) => ({ ...t }));
|
||||
if (step >= 3) { tasks[0].status = "active"; tasks[0].owner = "A"; }
|
||||
if (step >= 5) { tasks[1].status = "active"; tasks[1].owner = "B"; }
|
||||
if (step >= 6) { tasks[0].status = "complete"; }
|
||||
if (step >= 7) { tasks[2].status = "active"; tasks[2].owner = "C"; }
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function getActivePhase(step: number): Phase {
|
||||
if (step <= 1) return "idle";
|
||||
if (step === 2) return "poll";
|
||||
if (step === 3) return "claim";
|
||||
if (step === 4 || step === 5) return "work";
|
||||
if (step === 6) return "idle";
|
||||
return "claim";
|
||||
}
|
||||
|
||||
// Ring timer around an agent
|
||||
function TimerRing({ cx, cy, r, fill }: { cx: number; cy: number; r: number; fill: number }) {
|
||||
if (fill <= 0) return null;
|
||||
const circumference = 2 * Math.PI * (r + 4);
|
||||
const offset = circumference * (1 - fill);
|
||||
return (
|
||||
<motion.circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r + 4}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={3}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
style={{ transform: "rotate(-90deg)", transformOrigin: `${cx}px ${cy}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// FSM arrow between two states
|
||||
function FSMArrow({ from, to, active, inactiveStroke }: { from: Phase; to: Phase; active: boolean; inactiveStroke: string }) {
|
||||
const fState = FSM_STATES.find((s) => s.id === from)!;
|
||||
const tState = FSM_STATES.find((s) => s.id === to)!;
|
||||
const fPos = fsmPos(fState.angle);
|
||||
const tPos = fsmPos(tState.angle);
|
||||
|
||||
const dx = tPos.x - fPos.x;
|
||||
const dy = tPos.y - fPos.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
|
||||
const x1 = fPos.x + ux * FSM_STATE_R;
|
||||
const y1 = fPos.y + uy * FSM_STATE_R;
|
||||
const x2 = tPos.x - ux * (FSM_STATE_R + 6);
|
||||
const y2 = tPos.y - uy * (FSM_STATE_R + 6);
|
||||
|
||||
const perpX = -uy * 12;
|
||||
const perpY = ux * 12;
|
||||
const cx = (x1 + x2) / 2 + perpX;
|
||||
const cy = (y1 + y2) / 2 + perpY;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={active ? PHASE_COLORS[to] : inactiveStroke}
|
||||
strokeWidth={active ? 2 : 1}
|
||||
markerEnd="url(#fsm-arrowhead)"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutonomousAgents({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const agentStates = getAgentStates(step);
|
||||
const tasks = getTaskStates(step);
|
||||
const activePhase = getActivePhase(step);
|
||||
const agentNames = ["A", "B", "C"];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Autonomous Agent Cycle"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left panel: spatial view with agents and task board */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Spatial View</div>
|
||||
<svg viewBox="0 0 280 240" className="w-full">
|
||||
{/* Task board (small table in center) */}
|
||||
<rect x={BOARD_CX - 35} y={BOARD_CY - 20} width={70} height={40} rx={4}
|
||||
fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1}
|
||||
/>
|
||||
<text x={BOARD_CX} y={BOARD_CY - 8} textAnchor="middle" fontSize={7} fontWeight={600}
|
||||
fill={palette.nodeText}
|
||||
>
|
||||
Task Board
|
||||
</text>
|
||||
<text x={BOARD_CX} y={BOARD_CY + 4} textAnchor="middle" fontSize={6} fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
{tasks.filter((t) => t.status === "unclaimed").length} unclaimed
|
||||
</text>
|
||||
<text x={BOARD_CX} y={BOARD_CY + 14} textAnchor="middle" fontSize={6} fontFamily="monospace"
|
||||
fill="#10b981"
|
||||
>
|
||||
{tasks.filter((t) => t.status === "complete").length} complete
|
||||
</text>
|
||||
|
||||
{/* Agents */}
|
||||
{agentStates.map((state, i) => {
|
||||
const pos = agentPos(i);
|
||||
const isPulsing = state.phase === "work";
|
||||
const isPolling = state.phase === "poll";
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Dashed line from agent to board when polling */}
|
||||
{isPolling && (
|
||||
<motion.line
|
||||
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
|
||||
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
{/* Solid line from agent to board when claiming */}
|
||||
{state.phase === "claim" && (
|
||||
<motion.line
|
||||
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
|
||||
stroke="#3b82f6" strokeWidth={2}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timer ring */}
|
||||
<TimerRing cx={pos.x} cy={pos.y} r={AGENT_R} fill={state.timerFill} />
|
||||
|
||||
{/* Agent circle */}
|
||||
<motion.circle
|
||||
cx={pos.x} cy={pos.y} r={AGENT_R}
|
||||
fill={state.color}
|
||||
stroke={state.phase === "work" ? "#059669" : palette.nodeStroke}
|
||||
strokeWidth={1.5}
|
||||
animate={{
|
||||
scale: isPulsing ? [1, 1.1, 1] : 1,
|
||||
fill: state.color,
|
||||
}}
|
||||
transition={
|
||||
isPulsing
|
||||
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
|
||||
: { duration: 0.4 }
|
||||
}
|
||||
/>
|
||||
<text x={pos.x} y={pos.y + 1} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={11} fontWeight={700}
|
||||
>
|
||||
{agentNames[i]}
|
||||
</text>
|
||||
|
||||
{/* Task label below agent when claiming or working */}
|
||||
{state.taskClaim && (
|
||||
<motion.text
|
||||
x={pos.x} y={pos.y + AGENT_R + 12}
|
||||
textAnchor="middle" fontSize={7} fontFamily="monospace"
|
||||
fill={state.phase === "work" ? "#10b981" : "#3b82f6"}
|
||||
fontWeight={600}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{state.taskClaim}
|
||||
</motion.text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Task table below the spatial view */}
|
||||
<div className="mt-2 border border-zinc-200 rounded dark:border-zinc-700 overflow-hidden">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50 dark:bg-zinc-800">
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Task</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Status</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} className="border-t border-zinc-100 dark:border-zinc-800">
|
||||
<td className="px-2 py-1 font-mono text-zinc-700 dark:text-zinc-300">{task.name}</td>
|
||||
<td className="px-2 py-1">
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[9px] font-medium ${
|
||||
task.status === "complete"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: task.status === "active"
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono text-zinc-600 dark:text-zinc-400">{task.owner}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: FSM state machine diagram */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">FSM Cycle</div>
|
||||
<svg viewBox="0 0 220 220" className="w-full">
|
||||
<defs>
|
||||
<marker
|
||||
id="fsm-arrowhead"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Transition arrows */}
|
||||
{FSM_TRANSITIONS.map((t) => {
|
||||
const isActive =
|
||||
(activePhase === t.from) ||
|
||||
(activePhase === t.to && t.from === FSM_TRANSITIONS.find((tr) => tr.to === activePhase)?.from);
|
||||
return (
|
||||
<FSMArrow
|
||||
key={`${t.from}-${t.to}`}
|
||||
from={t.from}
|
||||
to={t.to}
|
||||
active={isActive}
|
||||
inactiveStroke={palette.nodeStroke}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* State circles */}
|
||||
{FSM_STATES.map((state) => {
|
||||
const pos = fsmPos(state.angle);
|
||||
const isActive = state.id === activePhase;
|
||||
return (
|
||||
<g key={state.id}>
|
||||
<motion.circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={FSM_STATE_R}
|
||||
fill={isActive ? PHASE_COLORS[state.id] : palette.nodeFill}
|
||||
stroke={isActive ? PHASE_COLORS[state.id] : palette.nodeStroke}
|
||||
strokeWidth={isActive ? 2 : 1}
|
||||
animate={{
|
||||
fill: isActive ? PHASE_COLORS[state.id] : palette.nodeFill,
|
||||
scale: isActive ? 1.1 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
fill={isActive ? "white" : palette.nodeText}
|
||||
>
|
||||
{state.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex flex-wrap gap-3 justify-center">
|
||||
{FSM_STATES.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: PHASE_COLORS[s.id] }} />
|
||||
<span className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={STEPS[step].title}
|
||||
stepDescription={STEPS[step].desc}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause, SkipBack, SkipForward, RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StepControlsProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onReset: () => void;
|
||||
isPlaying: boolean;
|
||||
onToggleAutoPlay: () => void;
|
||||
stepTitle: string;
|
||||
stepDescription: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StepControls({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
onPrev,
|
||||
onNext,
|
||||
onReset,
|
||||
isPlaying,
|
||||
onToggleAutoPlay,
|
||||
stepTitle,
|
||||
stepDescription,
|
||||
className,
|
||||
}: StepControlsProps) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Annotation */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<div className="mb-1 text-sm font-semibold text-blue-900 dark:text-blue-200">
|
||||
{stepTitle}
|
||||
</div>
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{stepDescription}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={currentStep === 0}
|
||||
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
title="Previous step"
|
||||
>
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleAutoPlay}
|
||||
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
title={isPlaying ? "Pause" : "Auto-play"}
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={currentStep === totalSteps - 1}
|
||||
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
title="Next step"
|
||||
>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: totalSteps }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full transition-colors",
|
||||
i === currentStep
|
||||
? "bg-blue-500"
|
||||
: i < currentStep
|
||||
? "bg-blue-300 dark:bg-blue-700"
|
||||
: "bg-zinc-200 dark:bg-zinc-700"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-zinc-400">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s01",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "one-tool-sufficiency",
|
||||
"title": "Why Bash Alone Is Enough",
|
||||
"description": "Bash can read files, write files, run arbitrary programs, pipe data between processes, and manage the filesystem. Any additional tool (read_file, write_file, etc.) would be a strict subset of what bash already provides. Adding more tools doesn't unlock new capabilities -- it just adds surface area for confusion. The model has to learn fewer tool schemas, and the implementation stays under 100 lines. This is the minimal viable agent: one tool, one loop.",
|
||||
"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:一个工具,一个循环。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "Bash だけで十分な理由",
|
||||
"description": "Bash はファイルの読み書き、任意のプログラムの実行、プロセス間のデータパイプ、ファイルシステムの管理が可能です。追加のツール(read_file、write_file など)は bash が既に提供している機能の部分集合に過ぎません。ツールを増やしても新しい能力は得られず、モデルが理解すべきインターフェースが増えるだけです。モデルが学習するスキーマは1つだけで、実装は100行以内に収まります。これが最小限の実用的エージェント:1つのツール、1つのループです。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"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 进程语义。每个子进程天然隔离关注点,因为它根本看不到父进程的上下文。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "再帰プロセス生成によるサブエージェント機構",
|
||||
"description": "エージェントが `python v0.py \"subtask\"` を実行すると、新しい LLM コンテキストを持つ完全に新しいプロセスが生成されます。この子プロセスは事実上サブエージェントです:独自のシステムプロンプト、会話履歴、タスクフォーカスを持ちます。完了すると、親プロセスは stdout で結果を受け取ります。これはフレームワークなしのサブエージェント委任です——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。各子プロセスは親のコンテキストを参照できないため、関心の分離が自然に実現されます。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "model-drives-everything",
|
||||
"title": "No Planning Framework -- The Model Decides",
|
||||
"description": "There is no planner, no task queue, no state machine. The system prompt tells the model how to approach problems, and the model decides what bash command to run next based on the conversation so far. This is intentional: at this level, adding a planning layer would be premature abstraction. The model's chain-of-thought IS the plan. The agent loop just keeps asking the model what to do until it stops requesting tools.",
|
||||
"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 循环只是不断询问模型下一步做什么,直到模型不再请求工具为止。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "計画フレームワークなし——モデルが全てを決定",
|
||||
"description": "プランナーもタスクキューも状態マシンもありません。システムプロンプトがモデルに問題の取り組み方を伝え、モデルがこれまでの会話に基づいて次に実行する bash コマンドを決定します。これは意図的な設計です:このレベルでは計画レイヤーの追加は時期尚早な抽象化です。モデルの思考の連鎖そのものが計画です。エージェントループはモデルがツールの呼び出しを止めるまで、次の行動を問い続けるだけです。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s02",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "four-tools-not-twenty",
|
||||
"title": "Why Exactly Four Tools",
|
||||
"description": "The four tools are bash, read_file, write_file, and edit_file. Together they cover roughly 95% of coding tasks. Bash handles execution and arbitrary commands. Read_file provides precise file reading with line numbers. Write_file creates or overwrites files. Edit_file does surgical string replacement. More tools would increase the model's cognitive load -- it has to decide which tool to use, and more options means more chances of picking the wrong one. Fewer tools also means fewer tool schemas to maintain and fewer edge cases to handle.",
|
||||
"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 需要维护、更少的边界情况需要处理。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "なぜ正確に4つのツールなのか",
|
||||
"description": "4つのツールは bash、read_file、write_file、edit_file です。これらでコーディングタスクの約95%をカバーします。Bash は実行と任意のコマンドを処理し、read_file は行番号付きの正確なファイル読み取りを提供し、write_file はファイルの作成・上書きを行い、edit_file は外科的な文字列置換を行います。ツールが増えるとモデルの認知負荷が増大し、どのツールを使うかの判断でミスが増えます。ツールが少ないことは、メンテナンスすべきスキーマとエッジケースの削減も意味します。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "model-as-agent",
|
||||
"title": "The Model IS the Agent",
|
||||
"description": "The core agent loop is trivially simple: while True, call the LLM, if it returns tool_use blocks then execute them and feed results back, if it returns only text then stop. There is no router, no decision tree, no workflow engine. The model itself decides what to do, when to stop, and how to recover from errors. The code is just plumbing that connects the model to tools. This is a philosophical stance: agent behavior emerges from the model, not from the framework.",
|
||||
"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 行为从模型中涌现,而非由框架定义。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "モデルそのものがエージェント",
|
||||
"description": "コアのエージェントループは極めてシンプルです:LLM を呼び出し続け、tool_use ブロックが返されればそれを実行して結果をフィードバックし、テキストのみが返されれば停止します。ルーターも決定木もワークフローエンジンもありません。モデル自体が何をすべきか、いつ停止するか、エラーからどう回復するかを決定します。コードはモデルとツールを接続する配管に過ぎません。これは設計思想です:エージェントの振る舞いはフレームワークではなくモデルから創発するものです。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "explicit-tool-schemas",
|
||||
"title": "JSON Schemas for Every Tool",
|
||||
"description": "Each tool defines a strict JSON schema for its input parameters. For example, edit_file requires old_string and new_string as exact strings, not regex patterns. This eliminates an entire class of bugs: the model can't pass malformed input because the API validates against the schema before execution. It also makes the model's intent unambiguous -- when it calls edit_file with specific strings, there's no parsing ambiguity about what it wants to change.",
|
||||
"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 时,不存在关于它想修改什么的解析歧义。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "全ツールに JSON Schema を定義",
|
||||
"description": "各ツールは入力パラメータに対して厳密な JSON Schema を定義しています。例えば edit_file は old_string と new_string を正確な文字列として要求し、正規表現は使いません。これにより一連のバグを排除できます:API がスキーマに対して実行前にバリデーションを行うため、モデルは不正な入力を渡せません。モデルの意図も明確になります――特定の文字列で edit_file を呼び出す際、何を変更したいかについて解析の曖昧さがありません。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s03",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "visible-planning",
|
||||
"title": "Making Plans Visible via TodoWrite",
|
||||
"description": "Instead of letting the model plan silently in its chain-of-thought, we force plans to be externalized through the TodoWrite tool. Each plan item has a status (pending, in_progress, completed) that gets tracked explicitly. This has three benefits: (1) users can see what the agent intends to do before it does it, (2) developers can debug agent behavior by inspecting the plan state, (3) the agent itself can refer back to its plan in later turns when earlier context has scrolled away.",
|
||||
"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 自身可以在后续轮次中引用计划,即使早期上下文已经滚出窗口。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "TodoWrite による計画の可視化",
|
||||
"description": "モデルが思考の連鎖の中で黙って計画するのではなく、TodoWrite ツールを通じて計画を外部化することを強制します。各計画項目には追跡可能なステータス(pending、in_progress、completed)があります。利点は3つ:(1) ユーザーがエージェントの意図を実行前に確認できる、(2) 開発者が計画状態を検査してデバッグできる、(3) エージェント自身が以前のコンテキストがスクロールアウトした後でも計画を参照できる。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "single-in-progress",
|
||||
"title": "Only One Task Can Be In-Progress",
|
||||
"description": "The TodoWrite tool enforces that at most one task has status 'in_progress' at any time. If the model tries to start a second task, it must first complete or abandon the current one. This constraint prevents a subtle failure mode: models that try to 'multitask' by interleaving work on multiple items tend to lose track of state and produce half-finished results. Sequential focus produces higher quality than parallel thrashing.",
|
||||
"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 状态。如果模型想开始第二个任务,必须先完成或放弃当前任务。这个约束防止了一种隐蔽的失败模式:试图通过交替处理多个项目来'多任务'的模型,往往会丢失状态并产出半成品。顺序执行的专注度远高于并行切换。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "同時に進行中にできるタスクは1つだけ",
|
||||
"description": "TodoWrite ツールは、同時に 'in_progress' 状態のタスクを最大1つに制限します。モデルが2つ目のタスクを開始しようとする場合、まず現在のタスクを完了または中断する必要があります。この制約は微妙な失敗モードを防ぎます:複数の項目を交互に処理して「マルチタスク」しようとするモデルは、状態を見失い中途半端な結果を生みがちです。逐次的な集中は並行的な切り替えよりも高品質な出力を生み出します。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "max-twenty-items",
|
||||
"title": "Maximum of 20 Plan Items",
|
||||
"description": "TodoWrite caps the plan at 20 items. This is a deliberate constraint against over-planning. Models tend to decompose tasks into increasingly fine-grained steps when unconstrained, producing 50-item plans where each step is trivial. Long plans are fragile: if step 15 fails, the remaining 35 steps may all be invalid. Short plans (under 20 items) stay at the right abstraction level and are easier to adapt when reality diverges from the plan.",
|
||||
"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 条以内的短计划保持在正确的抽象层级,更容易在现实偏离计划时做出调整。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "計画項目の上限は20個",
|
||||
"description": "TodoWrite は計画を20項目に制限します。これは過度な計画に対する意図的な制約です。制約がないとモデルはタスクをどんどん細かいステップに分解し、各ステップが些末な50項目の計画を作りがちです。長い計画は脆弱です:ステップ15が失敗すると残りの35ステップは全て無効になりかねません。20項目以内の短い計画は適切な抽象度を保ち、現実が計画から逸脱した際の適応が容易です。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s04",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "context-isolation",
|
||||
"title": "Subagents Get Fresh Context, Not Shared History",
|
||||
"description": "When a parent agent spawns a subagent via the Task tool, the subagent starts with a clean message history containing only the system prompt and the delegated task description. It does NOT inherit the parent's conversation. This is context isolation: the subagent can focus entirely on its specific subtask without being distracted by hundreds of messages from the parent's broader conversation. The result is returned to the parent as a single tool_result, collapsing potentially dozens of subagent turns into one concise answer.",
|
||||
"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 返回给父代理,将子代理可能数十轮的交互压缩为一个简洁的回答。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "サブエージェントは共有履歴ではなく新しいコンテキストを取得",
|
||||
"description": "親エージェントが Task ツールでサブエージェントを生成すると、サブエージェントはシステムプロンプトと委任されたタスク説明のみを含むクリーンなメッセージ履歴から開始します。親の会話は引き継ぎません。これがコンテキスト分離です:サブエージェントは親の広範な会話の何百ものメッセージに気を取られることなく、特定のサブタスクに完全に集中できます。結果は単一の tool_result として親に返され、サブエージェントの数十ターンが1つの簡潔な回答に凝縮されます。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tool-filtering",
|
||||
"title": "Explore Agents Cannot Write Files",
|
||||
"description": "When spawning a subagent with the 'Explore' type, it receives only read-only tools: bash (with restrictions), read_file, and search tools. It cannot call write_file or edit_file. This implements the principle of least privilege: an agent tasked with 'find all usages of function X' doesn't need write access. Removing write tools eliminates the risk of accidental file modification during exploration, and it also narrows the tool space so the model makes better decisions with fewer options.",
|
||||
"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 所有使用位置'的代理不需要写权限。移除写工具消除了探索过程中误修改文件的风险,同时缩小了工具空间,让模型在更少的选项中做出更好的决策。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "Explore エージェントはファイルを書き込めない",
|
||||
"description": "Explore タイプのサブエージェントを生成すると、読み取り専用ツールのみが提供されます:bash(制限付き)、read_file、検索ツール。write_file や edit_file は使えません。これは最小権限の原則の実装です:「関数 X の全使用箇所を見つける」タスクに書き込み権限は不要です。書き込みツールを除外することで探索中の誤ったファイル変更リスクを排除し、ツール空間を狭めてモデルがより良い判断を下せるようにします。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "no-recursive-task",
|
||||
"title": "Subagents Cannot Spawn Their Own Subagents",
|
||||
"description": "The Task tool is not included in the subagent's tool set. A subagent must complete its work directly -- it cannot delegate further. This prevents infinite delegation loops: without this constraint, an agent could spawn a subagent that spawns another subagent, each one re-delegating the same task in slightly different words, consuming tokens without making progress. One level of delegation handles the vast majority of use cases. If a task is too complex for a single subagent, the parent should decompose it differently.",
|
||||
"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 却毫无进展。一层委派足以处理绝大多数场景。如果任务对单个子代理来说太复杂,应该由父代理重新分解。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "サブエージェントは自身のサブエージェントを生成できない",
|
||||
"description": "Task ツールはサブエージェントのツールセットに含まれません。サブエージェントは作業を直接完了しなければならず、さらなる委任はできません。これにより無限委任ループを防止します:この制約がなければ、エージェントがサブエージェントを生成し、そのサブエージェントがさらにサブエージェントを生成し、それぞれが微妙に異なる言葉で同じタスクを再委任してトークンを消費するだけで進捗しない可能性があります。一段階の委任で大多数のユースケースに対応できます。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s05",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "tool-result-injection",
|
||||
"title": "Skills Inject via tool_result, Not System Prompt",
|
||||
"description": "When the agent invokes the Skill tool, the skill's content (a SKILL.md file) is returned as a tool_result in a user message, not injected into the system prompt. This is a deliberate caching optimization: the system prompt remains static across turns, which means API providers can cache it (Anthropic's prompt caching, OpenAI's system message caching). If skill content were in the system prompt, it would change every time a new skill is loaded, invalidating the cache. By putting dynamic content in tool_result, we keep the expensive system prompt cacheable while still getting skill knowledge into context.",
|
||||
"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 中,既保持了昂贵的系统提示词可缓存,又让技能知识进入了上下文。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "スキルはシステムプロンプトではなく tool_result で注入",
|
||||
"description": "エージェントが Skill ツールを呼び出すと、スキルの内容(SKILL.md ファイル)はシステムプロンプトへの注入ではなく、ユーザーメッセージ内の tool_result として返されます。これは意図的なキャッシュ最適化です:システムプロンプトはターン間で静的に保たれるため、API プロバイダーがキャッシュできます(Anthropic のプロンプトキャッシュ、OpenAI のシステムメッセージキャッシュ)。スキル内容がシステムプロンプト内にあると、新しいスキルをロードするたびにキャッシュが無効化されます。動的コンテンツを 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.",
|
||||
"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 部署技能——那会浪费上下文窗口空间,还可能用无关指令干扰模型。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "起動時ではなくオンデマンドでスキルを読み込み",
|
||||
"description": "スキルは起動時に読み込まれません。エージェントは最初、スキルの名前と説明(フロントマターから)のみを持ちます。エージェントが特定のスキルが必要だと判断すると、Skill ツールを呼び出して完全な SKILL.md の内容をコンテキストに読み込みます。これにより初期プロンプトを小さく保ちます。Python のバグを修正しているエージェントに Kubernetes デプロイのスキルは不要です――コンテキストウィンドウの無駄遣いであり、無関係な指示でモデルを混乱させかねません。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontmatter-body-split",
|
||||
"title": "YAML Frontmatter + Markdown Body in SKILL.md",
|
||||
"description": "Each SKILL.md file has two parts: YAML frontmatter (name, description, globs) and a markdown body (the actual instructions). The frontmatter serves as metadata for the skill registry -- it's what gets listed when the agent asks 'what skills are available?' The body is the payload that gets loaded on demand. This separation means you can list 100 skills (reading only frontmatter, a few bytes each) without loading 100 full instruction sets (potentially thousands of tokens each).",
|
||||
"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)。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "SKILL.md で YAML フロントマター + Markdown 本文",
|
||||
"description": "各 SKILL.md ファイルは2つの部分で構成されます:YAML フロントマター(名前、説明、globs)と Markdown 本文(実際の指示)。フロントマターはスキルレジストリのメタデータとして機能し、エージェントが「どんなスキルが利用可能か」と問い合わせた際に一覧表示されます。本文はオンデマンドで読み込まれるペイロードです。この分離により、100個のスキル一覧表示(各数バイトのフロントマターのみ読み取り)が100個の完全な指示セット(各数千トークン)のロードなしに可能になります。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"version": "s06",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "three-layer-compression",
|
||||
"title": "Three-Layer Compression Strategy",
|
||||
"description": "Context management uses three distinct layers, each with different cost/benefit profiles. (1) Microcompact runs every turn and is nearly free: it truncates tool_result blocks from older messages, stripping verbose command output that's no longer needed. (2) Auto_compact triggers when token count exceeds a threshold: it calls the LLM to generate a conversation summary, which is expensive but dramatically reduces context size. (3) Manual compact is user-triggered for explicit 'start fresh' moments. Layering these means the cheap operation runs constantly (keeping context tidy) while the expensive operation runs rarely (only when actually needed).",
|
||||
"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) 手动压缩由用户触发,用于明确的'重新开始'场景。分层意味着低成本操作持续运行(保持上下文整洁),而高成本操作很少触发(仅在真正需要时)。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "3層圧縮戦略",
|
||||
"description": "コンテキスト管理は、異なるコスト・効果プロファイルを持つ3つの層を使用します。(1) マイクロコンパクトは毎ターン実行されほぼ無コスト:古いメッセージの tool_result ブロックを切り詰め、不要な冗長出力を除去します。(2) 自動コンパクトはトークン数が閾値を超えると発動:LLM を呼び出して会話の要約を生成し、コストは高いがコンテキストサイズを劇的に削減します。(3) 手動コンパクトはユーザーが明示的に「最初からやり直し」する時に使用します。この階層化により、安価な操作が常に実行され(コンテキストを整頓)、高価な操作はめったに実行されません(本当に必要な時のみ)。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "min-savings-threshold",
|
||||
"title": "MIN_SAVINGS = 20,000 Tokens Before Compressing",
|
||||
"description": "Auto_compact only triggers when the estimated savings (current tokens minus estimated summary size) exceed 20,000 tokens. Compression is not free: the summary itself consumes tokens, plus there's the API call cost to generate it. If the conversation is only 25,000 tokens, compressing might save 5,000 tokens but cost an API call and produce a summary that's less coherent than the original. The 20K threshold ensures compression only happens when the savings meaningfully exceed the overhead.",
|
||||
"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 的阈值确保只在节省量明显超过开销时才进行压缩。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "圧縮前に MIN_SAVINGS = 20,000 トークンが必要",
|
||||
"description": "自動コンパクトは推定節約量(現在のトークン数マイナス推定要約サイズ)が20,000トークンを超えた場合にのみ発動します。圧縮は無料ではありません:要約自体がトークンを消費し、さらに生成のための API コール費用がかかります。会話が25,000トークンしかない場合、圧縮で5,000トークン節約できても、API コールが必要で元の会話より一貫性の低い要約になる可能性があります。20K の閾値は、節約量がオーバーヘッドを確実に上回る場合にのみ圧縮を実行することを保証します。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "summary-replaces-all",
|
||||
"title": "Summary Replaces ALL Messages, Not Partial History",
|
||||
"description": "When auto_compact fires, it generates a summary and replaces the ENTIRE message history with that summary. It does not keep the last N messages alongside the summary. This avoids a subtle coherence problem: if you keep recent messages plus a summary of older ones, the model sees two representations of overlapping content. The summary might say 'we decided to use approach X' while a recent message still shows the deliberation process, creating contradictory signals. A clean summary is a single coherent narrative.",
|
||||
"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',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "要約が部分的な履歴ではなく全メッセージを置換",
|
||||
"description": "自動コンパクトが発動すると、要約を生成してメッセージ履歴の全体をその要約で置換します。要約と並べて直近 N 件のメッセージを保持することはしません。これにより微妙な一貫性の問題を回避します:直近のメッセージと古いメッセージの要約を併存させると、モデルは重複するコンテンツの2つの表現を見ることになります。要約が「アプローチ X を使うことに決めた」と言う一方で、直近のメッセージにはまだ検討過程が表示されているかもしれず、矛盾するシグナルを生じます。クリーンな要約は単一の一貫した物語です。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "transcript-archival",
|
||||
"title": "Full Conversation Archived to JSONL on Disk",
|
||||
"description": "Even though context is compressed in memory, the full uncompressed conversation is appended to a JSONL file on disk. Every message, every tool call, every result -- nothing is lost. This means compression is a lossy operation on the in-memory context but a lossless operation on the permanent record. Post-hoc analysis (debugging agent behavior, computing token usage, training data extraction) can always work from the complete transcript. The JSONL format is append-only, making it safe for concurrent writes and easy to stream-process.",
|
||||
"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 格式仅追加写入,对并发写入安全,易于流式处理。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "完全な会話を JSONL としてディスクに保存",
|
||||
"description": "メモリ上でコンテキストが圧縮されても、完全な非圧縮会話はディスク上の JSONL ファイルに追記されます。全てのメッセージ、全てのツール呼び出し、全ての結果――何も失われません。圧縮はインメモリコンテキストに対しては不可逆ですが、永続記録に対しては可逆です。事後分析(エージェントの挙動デバッグ、トークン使用量の計算、学習データの抽出)は常に完全な記録から行えます。JSONL フォーマットは追記専用で、並行書き込みに安全であり行単位の処理が容易です。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s07",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "file-based-persistence",
|
||||
"title": "Tasks Stored as JSON Files, Not In-Memory",
|
||||
"description": "Tasks are persisted as JSON files in a .tasks/ directory on the filesystem instead of being held in memory. This has three critical benefits: (1) Tasks survive process crashes -- if the agent dies mid-task, the task board is still on disk when it restarts. (2) Multiple agents can read and write to the same task directory, enabling multi-agent coordination without shared memory. (3) Humans can inspect and manually edit task files for debugging. The filesystem becomes the shared database.",
|
||||
"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) 人类可以查看和手动编辑任务文件来调试。文件系统就是共享数据库。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "タスクをメモリではなく JSON ファイルとして保存",
|
||||
"description": "タスクはメモリ内ではなく .tasks/ ディレクトリに JSON ファイルとして永続化されます。3つの重要な利点があります:(1) プロセスのクラッシュ後もタスクが存続する――エージェントがタスク途中でクラッシュしても、再起動時にタスクボードはディスク上に残っています。(2) 複数のエージェントが同じタスクディレクトリを読み書きでき、共有メモリなしにマルチエージェント連携が可能になります。(3) 人間がデバッグのためにタスクファイルを検査・手動編集できます。ファイルシステムが共有データベースになります。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dependency-graph",
|
||||
"title": "Tasks Have blocks/blockedBy Dependency Fields",
|
||||
"description": "Each task can declare which other tasks it blocks (downstream dependents) and which tasks block it (upstream dependencies). An agent will not start a task that has unresolved blockedBy dependencies. This is essential for multi-agent coordination: when Agent A is writing the database schema and Agent B needs to write queries against it, Agent B's task is blockedBy Agent A's task. Without dependencies, both agents might start simultaneously and Agent B would work against a schema that doesn't exist yet.",
|
||||
"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 工作。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "タスクに blocks/blockedBy 依存関係フィールド",
|
||||
"description": "各タスクは、自分がブロックするタスク(下流の依存先)と、自分をブロックするタスク(上流の依存元)を宣言できます。エージェントは未解決の blockedBy 依存がある タスクを開始しません。これはマルチエージェント連携に不可欠です:エージェント A がデータベーススキーマを書いていてエージェント B がそれに対するクエリを書く必要がある場合、B のタスクは A のタスクにブロックされます。依存関係がなければ両エージェントが同時に開始し、B はまだ存在しないスキーマに対して作業することになります。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "task-replaces-todo",
|
||||
"title": "TaskManager Replaces TodoWrite",
|
||||
"description": "TaskManager is the multi-agent evolution of TodoWrite. Same core concept (a list of items with statuses) but with critical additions: file persistence (survives crashes), dependency tracking (blocks/blockedBy), ownership (which agent is working on what), and multi-process safety. TodoWrite was designed for a single agent tracking its own work in memory. TaskManager is designed for a team of agents coordinating through the filesystem. The API is intentionally similar so the conceptual upgrade path is clear.",
|
||||
"alternatives": "Keeping TodoWrite for single-agent use and adding TaskManager only for multi-agent scenarios would avoid breaking the single-agent experience. But maintaining two systems with overlapping functionality increases complexity. TaskManager is a strict superset of TodoWrite -- a single agent using TaskManager just ignores the multi-agent features.",
|
||||
"zh": {
|
||||
"title": "TaskManager 取代 TodoWrite",
|
||||
"description": "TaskManager 是 TodoWrite 的多代理进化版。核心概念相同(带状态的项目列表),但增加了关键能力:文件持久化(崩溃后存活)、依赖追踪(blocks/blockedBy)、所有权(哪个 agent 在处理什么)、以及多进程安全。TodoWrite 为单 agent 在内存中追踪自身工作而设计。TaskManager 为代理团队通过文件系统协调而设计。API 刻意保持相似,使概念升级路径清晰。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "TaskManager が TodoWrite を置き換え",
|
||||
"description": "TaskManager は TodoWrite のマルチエージェント進化版です。コア概念は同じ(ステータス付きの項目リスト)ですが、重要な追加があります:ファイル永続化(クラッシュ後も存続)、依存関係追跡(blocks/blockedBy)、所有権(どのエージェントが何を担当しているか)、マルチプロセス安全性。TodoWrite は単一エージェントがメモリ内で自身の作業を追跡するために設計されました。TaskManager はエージェントチームがファイルシステムを通じて連携するために設計されています。API は意図的に類似させ、概念的なアップグレードパスを明確にしています。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s08",
|
||||
"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.",
|
||||
"zh": {
|
||||
"title": "用 threading.Queue 作为通知总线",
|
||||
"description": "后台任务结果通过 threading.Queue 传递,而非直接回调。后台线程在工作完成时向队列放入通知,主 agent 循环在每次 LLM 调用前轮询队列。这种解耦很重要:后台线程无需了解主循环的状态或时序,只需往队列放入消息然后继续。主循环按自己的节奏取出消息——永远不会在 API 调用中途或工具执行中途。没有竞争条件,没有回调地狱。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "threading.Queue を通知バスとして使用",
|
||||
"description": "バックグラウンドタスクの結果は直接コールバックではなく threading.Queue を通じて配信されます。バックグラウンドスレッドは作業完了時にキューに通知を投入します。メインのエージェントループは各 LLM 呼び出しの前にキューをポーリングします。この疎結合が重要です:バックグラウンドスレッドはメインループの状態やタイミングを一切知る必要がありません。キューにメッセージを入れて先に進むだけです。メインループは自分のペースで取り出します――API 呼び出しの途中でもツール実行の途中でもありません。レースコンディションもコールバック地獄もありません。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"zh": {
|
||||
"title": "后台任务以守护线程运行",
|
||||
"description": "后台任务线程以 daemon=True 创建。在 Python 中,守护线程在主线程退出时自动被终止。这防止了一个常见问题:如果主 agent 完成工作并退出,但后台线程仍在运行(等待一个长时间 API 调用或陷入循环),进程会无限挂起。使用守护线程,退出是干净的——主线程结束,所有守护线程自动终止,进程退出。没有僵尸进程,不需要清理代码。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "バックグラウンドタスクはデーモンスレッドとして実行",
|
||||
"description": "バックグラウンドタスクのスレッドは daemon=True で作成されます。Python ではデーモンスレッドはメインスレッドの終了時に自動的に終了されます。これにより一般的な問題を防ぎます:メインエージェントが作業を完了して終了しても、バックグラウンドスレッドがまだ実行中(長い API 呼び出しを待機、ループに陥っている)だとプロセスが無限にハングします。デーモンスレッドならクリーンに終了できます――メインスレッドが終了すると全デーモンスレッドが自動終了し、プロセスが終了します。ゾンビプロセスもクリーンアップコードも不要です。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"zh": {
|
||||
"title": "带类型标签的结构化通知格式",
|
||||
"description": "后台任务的通知使用结构化格式:{\"type\": \"attachment\", \"attachment\": {status, result, ...}},而非纯文本字符串。类型标签让主循环可以区别处理不同通知类型:attachment 可能作为 tool_result 注入对话,而 status_update 可能只更新进度指示器。机器可读的通知还支持程序化过滤(只显示错误、抑制进度更新)和 UI 渲染(将状态显示为进度条而非原始文本)。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "型タグ付き構造化通知フォーマット",
|
||||
"description": "バックグラウンドタスクからの通知は構造化フォーマットを使用します:プレーンテキストではなく {\"type\": \"attachment\", \"attachment\": {status, result, ...}} です。型タグによりメインループは異なる通知タイプを異なる方法で処理できます:attachment は会話に tool_result として注入され、status_update は進捗インジケーターの更新のみを行うかもしれません。機械可読な通知はプログラム的なフィルタリング(エラーのみ表示、進捗更新の抑制)や UI レンダリング(ステータスを生テキストではなくプログレスバーとして表示)も可能にします。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s09",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "teammate-vs-subagent",
|
||||
"title": "Persistent Teammates vs One-Shot Subagents",
|
||||
"description": "In v3, subagents are ephemeral: spawn, do one task, return result, die. Their knowledge dies with them. In v8, teammates are persistent threads with identity (name, role) and config files. A teammate can complete task A, then be assigned task B, carrying forward everything it learned. This is the difference between hiring a contractor for one job and having a team member. Persistent teammates accumulate project knowledge, understand established patterns, and don't need to re-read the same files for every task.",
|
||||
"alternatives": "One-shot subagents (v3 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": "在 v3 中,子代理是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 v8 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。这就是雇一个临时工做一个项目和拥有一个团队成员之间的区别。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "永続的なチームメイト vs 使い捨てサブエージェント",
|
||||
"description": "v3 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。v8 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。これは1つの仕事のために請負業者を雇うことと、チームメンバーを持つことの違いです。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "file-based-team-config",
|
||||
"title": "Team Config Persisted to .teams/{name}/config.json",
|
||||
"description": "Team structure (member names, roles, agent IDs) is stored in a JSON config file, not in any agent's memory. Any agent can discover its teammates by reading the config file -- no need for a discovery service or shared memory. If an agent crashes and restarts, it reads the config to find out who else is on the team. This is consistent with the v6 philosophy: the filesystem is the coordination layer. Config files are also human-readable, making it easy to manually add/remove team members or debug team setup issues.",
|
||||
"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 崩溃并重启,它读取配置即可知道团队中还有谁。这与 v6 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "チーム設定を .teams/{name}/config.json に永続化",
|
||||
"description": "チーム構成(メンバー名、役割、エージェント ID)はエージェントのメモリではなく JSON 設定ファイルに保存されます。どのエージェントも設定ファイルを読むことでチームメイトを発見できます――ディスカバリーサービスや共有メモリは不要です。エージェントがクラッシュして再起動した場合、設定を読んで他のチームメンバーを把握します。これは v6 の思想と一貫しています:ファイルシステムが連携レイヤーです。設定ファイルは人間が読めるため、チームメンバーの手動追加・削除やチーム設定問題のデバッグが容易です。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tool-filtering-by-role",
|
||||
"title": "Teammates Get Subset of Tools, Lead Gets All",
|
||||
"description": "The team lead receives ALL_TOOLS (including TeamCreate, SendMessage, TaskCreate, etc.) while teammates receive TEAMMATE_TOOLS (a reduced set focused on task execution). This enforces a clear separation of concerns: teammates focus on doing work (coding, testing, researching), while the lead focuses on coordination (creating tasks, assigning work, managing communication). Giving teammates coordination tools would let them create their own sub-teams or reassign tasks, undermining the lead's ability to maintain a coherent plan.",
|
||||
"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. A permission system (any agent can request elevation) adds flexibility but also complexity. Static role-based filtering is predictable and easy to reason about.",
|
||||
"zh": {
|
||||
"title": "队友获得工具子集,组长获得全部工具",
|
||||
"description": "团队组长获得 ALL_TOOLS(包括 TeamCreate、SendMessage、TaskCreate 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "チームメイトはツールのサブセット、リーダーは全ツール",
|
||||
"description": "チームリーダーは ALL_TOOLS(TeamCreate、SendMessage、TaskCreate など含む)を受け取り、チームメイトは TEAMMATE_TOOLS(タスク実行に特化した縮小セット)を受け取ります。これにより明確な関心の分離が強制されます:チームメイトは作業(コーディング、テスト、調査)に集中し、リーダーは調整(タスク作成、作業割り当て、コミュニケーション管理)に集中します。チームメイトに調整ツールを与えると、独自のサブチーム作成やタスクの再割り当てが可能になり、リーダーの一貫した計画維持能力が損なわれます。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s10",
|
||||
"decisions": [
|
||||
{
|
||||
"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.",
|
||||
"zh": {
|
||||
"title": "JSONL 收件箱文件而非共享内存",
|
||||
"description": "每个队友都有自己的收件箱文件(团队目录中的 JSONL 文件)。发送消息意味着向接收者的收件箱文件追加一行 JSON。读取消息意味着读取收件箱文件并追踪上次读到的行。JSONL 天然是仅追加的,这意味着并发写入不会破坏彼此的数据(追加到不同的文件位置)。这在无需共享内存、互斥锁或 IPC 机制的情况下跨进程工作。它也是崩溃安全的:如果写入者在追加中途崩溃,最坏情况是一行不完整的数据,读取者可以跳过。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "共有メモリではなく JSONL インボックスファイル",
|
||||
"description": "各チームメイトはチームディレクトリ内に独自のインボックスファイル(JSONL ファイル)を持ちます。メッセージの送信は受信者のインボックスファイルに JSON 行を追記することです。メッセージの読み取りはインボックスファイルを読んで最後に読んだ行を追跡することです。JSONL は本質的に追記専用で、並行ライターが互いのデータを破壊しません(異なるファイル位置への追記)。共有メモリ、ミューテックス、IPC メカニズムなしにプロセス間で動作します。クラッシュにも安全です:ライターが追記途中でクラッシュしても、最悪の場合は不完全な1行だけでリーダーはスキップできます。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "five-message-types",
|
||||
"title": "Exactly Five Message Types Cover All Coordination Patterns",
|
||||
"description": "The messaging system supports exactly five types: (1) 'message' for point-to-point communication between two agents, (2) 'broadcast' for team-wide announcements, (3) 'shutdown_request' for graceful termination, (4) 'shutdown_response' for acknowledging shutdown, (5) 'plan_approval_response' for the lead to approve or reject a teammate's plan. These five types map to the fundamental coordination patterns: direct communication, broadcast, lifecycle management, and approval workflows. Adding more types (e.g., priority_message, status_update) would increase complexity without enabling new coordination patterns.",
|
||||
"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 and the model can reliably choose the right one.",
|
||||
"zh": {
|
||||
"title": "恰好五种消息类型覆盖所有协调模式",
|
||||
"description": "消息系统恰好支持五种类型:(1) message 用于两个 agent 间的点对点通信;(2) broadcast 用于全团队公告;(3) shutdown_request 用于优雅终止;(4) shutdown_response 用于确认终止;(5) plan_approval_response 用于组长批准或拒绝队友的计划。这五种类型映射到基本协调模式:直接通信、广播、生命周期管理和审批流程。增加更多类型(如 priority_message、status_update)只会增加复杂度而不会启用新的协调模式。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "正確に5つのメッセージタイプで全連携パターンをカバー",
|
||||
"description": "メッセージングシステムは正確に5つのタイプをサポートします:(1) message は2つのエージェント間のポイントツーポイント通信、(2) broadcast はチーム全体への通知、(3) shutdown_request はグレースフルな終了要求、(4) shutdown_response はシャットダウンの確認応答、(5) plan_approval_response はリーダーによるチームメイトの計画の承認・却下。これら5タイプは基本的な連携パターンに対応します:直接通信、ブロードキャスト、ライフサイクル管理、承認ワークフロー。タイプを増やしても(priority_message、status_update など)新たな連携パターンは生まれず、複雑さが増すだけです。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "inbox-before-api-call",
|
||||
"title": "Check Inbox Before Every LLM Call",
|
||||
"description": "Teammates check their inbox file at the top of every agent loop iteration, before calling the LLM API. This ensures maximum responsiveness to incoming messages: a shutdown request is seen within one loop iteration (typically seconds), not after the current task completes (potentially minutes). The inbox check is cheap (read a small file, check if new lines exist) compared to the LLM call (seconds of latency, thousands of tokens). This placement also means incoming messages can influence the next LLM call -- a message saying 'stop working on X, switch to Y' takes effect immediately.",
|
||||
"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'的消息会立即生效。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "毎回の LLM 呼び出し前にインボックスを確認",
|
||||
"description": "チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。インボックスの確認は安価で(小さなファイルを読み、新しい行があるか確認)、LLM 呼び出し(秒単位のレイテンシ、数千トークン)と比べて微々たるものです。この配置により受信メッセージが次の LLM 呼び出しに影響できます――「X の作業を止めて Y に切り替えて」というメッセージが即座に有効になります。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "s11",
|
||||
"decisions": [
|
||||
{
|
||||
"id": "polling-not-events",
|
||||
"title": "Polling for Unclaimed Tasks Instead of Event-Driven Notification",
|
||||
"description": "Autonomous teammates poll the shared task board every ~1 second to find unclaimed tasks, rather than waiting for event-driven notifications. Polling is fundamentally simpler than pub/sub: there's no subscription management, no event routing, no missed-event bugs. With file-based persistence, polling is just 'read the directory listing' -- a cheap operation that works regardless of how many agents are running. The 1-second interval balances responsiveness (new tasks are discovered quickly) against filesystem overhead (not hammering the disk with reads).",
|
||||
"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 秒的间隔平衡了响应性(新任务被快速发现)和文件系统开销(不会过度读取磁盘)。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "イベント駆動通知ではなくポーリングで未割り当てタスクを発見",
|
||||
"description": "自律的なチームメイトはイベント駆動の通知を待つのではなく、約1秒ごとに共有タスクボードをポーリングして未割り当てタスクを探します。ポーリングはパブ/サブより根本的にシンプルです:サブスクリプション管理、イベントルーティング、イベント欠落バグがありません。ファイルベースの永続化では、ポーリングは「ディレクトリ一覧を読む」だけで、実行中のエージェント数に関係なく動作する安価な操作です。1秒間隔は応答性(新タスクの迅速な発見)とファイルシステムのオーバーヘッド(ディスク読み取りの過負荷回避)のバランスを取っています。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "idle-timeout",
|
||||
"title": "60-Second Idle Timeout Before Self-Termination",
|
||||
"description": "When an autonomous teammate has no tasks to work on and no messages in its inbox, it waits up to 60 seconds before giving up and shutting down. This prevents zombie teammates that wait forever for work that never comes -- a real problem when the lead forgets to send a shutdown request, or when all remaining tasks are blocked on external events. The 60-second window is long enough that a brief gap between task completions and new task creation won't cause premature shutdown, but short enough that unused teammates don't waste resources.",
|
||||
"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 秒窗口足够长,不会因为任务完成到新任务创建之间的短暂间隔而导致过早关闭;又足够短,不会让闲置队友浪费资源。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "60秒のアイドルタイムアウトで自動終了",
|
||||
"description": "自律的なチームメイトが作業するタスクもインボックスのメッセージもない場合、最大60秒待ってから諦めてシャットダウンします。これにより永遠に来ない仕事を待ち続けるゾンビチームメイトを防ぎます――リーダーがシャットダウンリクエストの送信を忘れたり、残りのタスクが全て外部イベントでブロックされている場合に実際に起こる問題です。60秒のウィンドウはタスク完了から新タスク作成までの短い間隔で早期シャットダウンが起きない十分な長さであり、かつ未使用のチームメイトがリソースを浪費しない十分な短さです。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "identity-after-compression",
|
||||
"title": "Re-Inject Teammate Identity After Context Compression",
|
||||
"description": "When auto_compact compresses the conversation, the resulting summary loses crucial metadata: the teammate's name, which team it belongs to, and its agent_id. Without this information, the teammate can't claim tasks (tasks are owned by name), can't check its inbox (inbox files are keyed by agent_id), and can't identify itself in messages. So after every auto_compact, the system re-injects a structured identity block into the conversation: 'You are [name] on team [team], your agent_id is [id], your inbox is at [path].' This is the minimum context needed for the teammate to remain functional after memory loss.",
|
||||
"alternatives": "Putting identity in the system prompt (which survives compression) would avoid this problem, but violates the cache-friendly static-system-prompt design from v4. 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]。'这是队友在记忆丢失后保持功能所需的最小上下文。"
|
||||
},
|
||||
"ja": {
|
||||
"title": "コンテキスト圧縮後にチームメイトのアイデンティティを再注入",
|
||||
"description": "自動コンパクトが会話を圧縮すると、生成された要約は重要なメタデータを失います:チームメイトの名前、所属チーム、agent_id。この情報がなければチームメイトはタスクを申告できず(タスクは名前で所有)、インボックスを確認できず(インボックスファイルは agent_id をキーとする)、メッセージで自分を識別できません。そのため自動コンパクトの後、システムは構造化されたアイデンティティブロックを会話に再注入します:「あなたは [team] チームの [name] です。agent_id は [id]、インボックスは [path] にあります。」これはメモリ喪失後もチームメイトが機能し続けるために必要な最小限のコンテキストです。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { FlowNode, FlowEdge } from "@/types/agent-data";
|
||||
|
||||
export interface FlowDefinition {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
}
|
||||
|
||||
const FLOW_WIDTH = 600;
|
||||
const COL_CENTER = FLOW_WIDTH / 2;
|
||||
const COL_LEFT = 140;
|
||||
const COL_RIGHT = FLOW_WIDTH - 140;
|
||||
|
||||
export const EXECUTION_FLOWS: Record<string, FlowDefinition> = {
|
||||
s01: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "bash", label: "Execute Bash", type: "subprocess", x: COL_LEFT, y: 280 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_LEFT, y: 360 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "bash", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "bash", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s02: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "dispatch", label: "Tool Dispatch", type: "process", x: COL_LEFT, y: 280 },
|
||||
{ id: "exec", label: "bash / read / write / edit", type: "subprocess", x: COL_LEFT, y: 360 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_LEFT, y: 440 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "dispatch", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "dispatch", to: "exec" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s03: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "todo", label: "Create Todos", type: "process", x: COL_CENTER, y: 100 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 180 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 260 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT, y: 340 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_LEFT, y: 410 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 340 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "todo" },
|
||||
{ from: "todo", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "exec", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s04: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_task", label: "task tool?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "spawn", label: "Spawn Subagent\n(fresh messages[])", type: "subprocess", x: 60, y: 380 },
|
||||
{ id: "sub_loop", label: "Subagent Loop", type: "process", x: 60, y: 460 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 380 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 540 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_task", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_task", to: "spawn", label: "task" },
|
||||
{ from: "is_task", to: "exec", label: "other" },
|
||||
{ from: "spawn", to: "sub_loop" },
|
||||
{ from: "sub_loop", to: "append" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s05: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_skill", label: "load_skill?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "load", label: "Read SKILL.md", type: "subprocess", x: 60, y: 370 },
|
||||
{ id: "inject", label: "Inject via\ntool_result", type: "process", x: 60, y: 450 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 370 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 530 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_skill", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_skill", to: "load", label: "skill" },
|
||||
{ from: "is_skill", to: "exec", label: "other" },
|
||||
{ from: "load", to: "inject" },
|
||||
{ from: "inject", to: "append" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s06: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "compress_check", label: "Over token\nlimit?", type: "decision", x: COL_CENTER, y: 110 },
|
||||
{ id: "compress", label: "Compress Context", type: "subprocess", x: COL_RIGHT, y: 110 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 200 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 280 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT, y: 360 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_LEFT, y: 430 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 360 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "compress_check" },
|
||||
{ from: "compress_check", to: "compress", label: "yes" },
|
||||
{ from: "compress_check", to: "llm", label: "no" },
|
||||
{ from: "compress", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "exec", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "compress_check" },
|
||||
],
|
||||
},
|
||||
s07: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "is_task", label: "task_manager?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "crud", label: "CRUD Task\n(file-based)", type: "subprocess", x: 60, y: 370 },
|
||||
{ id: "dep_check", label: "Check\nDependencies", type: "process", x: 60, y: 450 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 370 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 530 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_task", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_task", to: "crud", label: "task" },
|
||||
{ from: "is_task", to: "exec", label: "other" },
|
||||
{ from: "crud", to: "dep_check" },
|
||||
{ from: "dep_check", to: "append" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s08: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 190 },
|
||||
{ id: "bg_check", label: "Background?", type: "decision", x: COL_LEFT, y: 280 },
|
||||
{ id: "bg_spawn", label: "Spawn Thread", type: "subprocess", x: 60, y: 370 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 370 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 450 },
|
||||
{ id: "notify", label: "Notification\nQueue", type: "process", x: 60, y: 450 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 280 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "bg_check", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "bg_check", to: "bg_spawn", label: "bg" },
|
||||
{ from: "bg_check", to: "exec", label: "fg" },
|
||||
{ from: "bg_spawn", to: "notify" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
{ from: "notify", to: "llm" },
|
||||
],
|
||||
},
|
||||
s09: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call\n(team lead)", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 200 },
|
||||
{ id: "is_team", label: "Team tool?", type: "decision", x: COL_LEFT, y: 290 },
|
||||
{ id: "spawn", label: "Spawn\nTeammate", type: "subprocess", x: 60, y: 390 },
|
||||
{ id: "msg", label: "Send Message\n(JSONL inbox)", type: "subprocess", x: 60, y: 470 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 390 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 550 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 290 },
|
||||
{ id: "teammate", label: "Teammate Agent\n(own loop)", type: "process", x: COL_RIGHT, y: 470 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_team", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_team", to: "spawn", label: "spawn" },
|
||||
{ from: "is_team", to: "exec", label: "other" },
|
||||
{ from: "spawn", to: "teammate" },
|
||||
{ from: "spawn", to: "msg" },
|
||||
{ from: "msg", to: "append" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s10: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "llm", label: "LLM Call\n(team lead)", type: "process", x: COL_CENTER, y: 110 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 200 },
|
||||
{ id: "is_proto", label: "Protocol?", type: "decision", x: COL_LEFT, y: 290 },
|
||||
{ id: "shutdown", label: "Shutdown\nRequest", type: "subprocess", x: 60, y: 390 },
|
||||
{ id: "fsm", label: "FSM:\npending->approved", type: "process", x: 60, y: 470 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT + 80, y: 390 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_CENTER, y: 550 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 290 },
|
||||
{ id: "teammate", label: "Teammate\nreceives request_id", type: "process", x: COL_RIGHT, y: 470 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "is_proto", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "is_proto", to: "shutdown", label: "shutdown" },
|
||||
{ from: "is_proto", to: "exec", label: "other" },
|
||||
{ from: "shutdown", to: "fsm" },
|
||||
{ from: "fsm", to: "teammate" },
|
||||
{ from: "teammate", to: "append" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
],
|
||||
},
|
||||
s11: {
|
||||
nodes: [
|
||||
{ id: "start", label: "User Input", type: "start", x: COL_CENTER, y: 30 },
|
||||
{ id: "inbox", label: "Check Inbox", type: "process", x: COL_CENTER, y: 100 },
|
||||
{ id: "llm", label: "LLM Call", type: "process", x: COL_CENTER, y: 180 },
|
||||
{ id: "tool_check", label: "tool_use?", type: "decision", x: COL_CENTER, y: 260 },
|
||||
{ id: "exec", label: "Execute Tool", type: "subprocess", x: COL_LEFT, y: 340 },
|
||||
{ id: "append", label: "Append Result", type: "process", x: COL_LEFT, y: 410 },
|
||||
{ id: "end", label: "Output", type: "end", x: COL_RIGHT, y: 340 },
|
||||
{ id: "idle", label: "Idle Cycle", type: "process", x: COL_RIGHT, y: 420 },
|
||||
{ id: "poll", label: "Poll Tasks\n+ Auto-Claim", type: "subprocess", x: COL_RIGHT, y: 500 },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "inbox" },
|
||||
{ from: "inbox", to: "llm" },
|
||||
{ from: "llm", to: "tool_check" },
|
||||
{ from: "tool_check", to: "exec", label: "yes" },
|
||||
{ from: "tool_check", to: "end", label: "no" },
|
||||
{ from: "exec", to: "append" },
|
||||
{ from: "append", to: "llm" },
|
||||
{ from: "end", to: "idle" },
|
||||
{ from: "idle", to: "poll" },
|
||||
{ from: "poll", to: "inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getFlowForVersion(version: string): FlowDefinition | null {
|
||||
return EXECUTION_FLOWS[version] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
[
|
||||
{
|
||||
"version": "s01",
|
||||
"locale": "en",
|
||||
"title": "s01: The Agent Loop",
|
||||
"content": "# s01: The Agent Loop\n\n> The entire secret of AI coding agents is a while loop that feeds tool results back to the model until the model decides to stop.\n\n## The Problem\n\nWhy can't a language model just answer a coding question? Because coding\nrequires _interaction with the real world_. The model needs to read files,\nrun tests, check errors, and iterate. A single prompt-response pair cannot\ndo this.\n\nWithout the agent loop, you would have to copy-paste outputs back into the\nmodel yourself. The user becomes the loop. The agent loop automates this:\ncall the model, execute whatever tools it asks for, feed the results back,\nrepeat until the model says \"I'm done.\"\n\nConsider a simple task: \"Create a Python file that prints hello.\" The model\nneeds to (1) decide to write a file, (2) write it, (3) verify it works.\nThat is three tool calls minimum. Without a loop, each one requires manual\nhuman intervention.\n\n## The Solution\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## How It Works\n\n1. The user provides a prompt. It becomes the first message.\n\n```python\nhistory.append({\"role\": \"user\", \"content\": query})\n```\n\n2. The messages array is sent to the LLM along with the tool definitions.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. The assistant response is appended to messages.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. We check the stop reason. If the model did not call a tool, the loop\n ends. This is the only exit condition.\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. For each tool_use block in the response, execute the tool (bash in this\n session) and collect results.\n\n```python\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 })\n```\n\n6. The results are appended as a user message, and the loop continues.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## Key Code\n\nThe minimum viable agent -- the entire pattern in under 30 lines\n(from `agents/s01_agent_loop.py`, lines 66-86):\n\n```python\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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## What Changed\n\nThis is session 1 -- the starting point. There is no prior session.\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## Design Rationale\n\nThis loop is the universal foundation of all LLM-based agents. Production implementations add error handling, token counting, streaming, and retry logic, but the fundamental structure is unchanged. The simplicity is the point: one exit condition (`stop_reason != \"tool_use\"`) controls the entire flow. Everything else in this course -- tools, planning, compression, teams -- layers on top of this loop without modifying it. Understanding this loop means understanding every agent.\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\nExample prompts to try:\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"
|
||||
},
|
||||
{
|
||||
"version": "s02",
|
||||
"locale": "en",
|
||||
"title": "s02: Tools",
|
||||
"content": "# s02: Tools\n\n> A dispatch map routes tool calls to handler functions -- the loop itself does not change at all.\n\n## The Problem\n\nWith only `bash`, the agent shells out for everything: reading files,\nwriting files, editing files. This works but is fragile. `cat` output\ngets truncated unpredictably. `sed` replacements fail on special\ncharacters. The model wastes tokens constructing shell pipelines when\na direct function call would be simpler.\n\nMore importantly, bash is a security surface. Every bash call can do\nanything the shell can do. With dedicated tools like `read_file` and\n`write_file`, you can enforce path sandboxing and block dangerous\npatterns at the tool level rather than hoping the model avoids them.\n\nThe insight is that adding tools does not require changing the loop.\nThe loop from s01 stays identical. You add entries to the tools array,\nadd handler functions, and wire them together with a dispatch map.\n\n## The 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. Define handler functions for each tool. Each takes keyword arguments\n matching the tool's input_schema and returns a string result.\n\n```python\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. Create the dispatch map linking 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 agent loop, look up the handler by name instead of hardcoding.\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)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. Path sandboxing prevents the model from escaping the workspace.\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```\n\n## Key Code\n\nThe dispatch pattern (from `agents/s02_tool_use.py`, lines 93-129):\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\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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) 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 messages.append({\"role\": \"user\", \"content\": results})\n```\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## Design Rationale\n\nThe dispaLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s03",
|
||||
"locale": "en",
|
||||
"title": "s03: TodoWrite",
|
||||
"content": "# s03: TodoWrite\n\n> A TodoManager lets the agent track its own progress, and a nag reminder injection forces it to keep updating when it forgets.\n\n## The Problem\n\nWhen an agent works on a multi-step task, it often loses track of what it\nhas done and what remains. Without explicit planning, the model might repeat\nwork, skip steps, or wander off on tangents. The user has no visibility\ninto the agent's internal plan.\n\nThis is worse than it sounds. Long conversations cause the model to \"drift\"\n-- the system prompt fades in influence as the context window fills with\ntool results. A 10-step refactoring task might complete steps 1-3, then\nthe model starts improvising because it forgot steps 4-10 existed.\n\nThe solution is structured state: a TodoManager that the model writes to\nexplicitly. The model creates a plan, marks items in_progress as it works,\nand marks them completed when done. A nag reminder injects a nudge if the\nmodel goes 3+ rounds without updating its todos.\n\nTeaching simplification: the nag threshold of 3 rounds is set low for\nteaching visibility. Production agents typically use a higher threshold\naround 10 to avoid excessive prompting.\n\n## The 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. The TodoManager validates and stores a list of items with statuses.\n Only one item can be `in_progress` at a time.\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\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 is added to the dispatch map like any other tool.\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. The nag reminder injects a `<reminder>` tag into the tool_result\n messages when the model goes 3+ rounds without calling `todo`.\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. The system prompt instructs the model to use todos for planning.\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## Key Code\n\nThe TodoManager and nag injection (from `agents/s03_todo_write.py`,\nlines 51-85 and 158-187):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one in_progress\")\n self.items = validated\n return self.render()\n\n# In agent_loop:\nif rounds_since_todo >= 3:\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n## What Changed From s02\n\n| Component | BLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s04",
|
||||
"locale": "en",
|
||||
"title": "s04: Subagents",
|
||||
"content": "# s04: Subagents\n\n> A subagent runs with a fresh messages list, shares the filesystem with the parent, and returns only a summary -- keeping the parent context clean.\n\n## The Problem\n\nAs the agent works, its messages array grows. Every tool call, every file\nread, every bash output accumulates. After 20-30 tool calls, the context\nwindow is crowded with irrelevant history. Reading a 500-line file to\nanswer a quick question permanently adds 500 lines to the context.\n\nThis is particularly bad for exploratory tasks. \"What testing framework\ndoes this project use?\" might require reading 5 files, but the parent\nagent does not need all 5 file contents in its history -- it just needs\nthe answer: \"pytest with conftest.py configuration.\"\n\nThe solution is process isolation: spawn a child agent with `messages=[]`.\nThe child explores, reads files, runs commands. When it finishes, only its\nfinal text response returns to the parent. The child's entire message\nhistory is discarded.\n\n## The 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.\nSubagent context is discarded.\n```\n\n## How It Works\n\n1. The parent agent gets a `task` tool that triggers subagent spawning.\n 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\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. The subagent starts with a fresh messages list containing only\n the delegated prompt. It shares the same filesystem.\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({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. Only the final text returns to the parent. The child's 30+ tool\n call history is discarded.\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. The parent receives this summary as a normal tool_result.\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## Key Code\n\nThe subagent function (from `agents/s04_subagent.py`,\nlines 110-128):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\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## 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 Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s05",
|
||||
"locale": "en",
|
||||
"title": "s05: Skills",
|
||||
"content": "# s05: Skills\n\n> Two-layer skill injection avoids system prompt bloat by putting skill names in the system prompt (cheap) and full skill bodies in tool_result (on demand).\n\n## The Problem\n\nYou want the agent to follow specific workflows for different domains:\ngit conventions, testing patterns, code review checklists. The naive\napproach is to put everything in the system prompt. But the system prompt\nhas limited effective attention -- too much text and the model starts\nignoring parts of it.\n\nIf you have 10 skills at 2000 tokens each, that is 20,000 tokens of system\nprompt. The model pays attention to the beginning and end but skims the\nmiddle. Worse, most of those skills are irrelevant to any given task. A\nfile editing task does not need the git workflow instructions.\n\nThe two-layer approach solves this: Layer 1 puts short skill descriptions\nin the system prompt (~100 tokens per skill). Layer 2 loads the full skill\nbody into a tool_result only when the model calls `load_skill`. The model\nlearns what skills exist (cheap) and loads them on demand (only when\nrelevant).\n\n## The 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| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## How It Works\n\n1. Skill files live in `.skills/` as Markdown with YAML frontmatter.\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. The SkillLoader parses frontmatter and separates metadata from body.\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. Layer 1: `get_descriptions()` returns short lines for the system prompt.\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. Layer 2: `get_content()` returns the full body wrapped in `<skill>` tags.\n\n```python\ndef 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\n5. The `load_skill` tool is just another entry in the dispatch map.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## Key Code\n\nThe SkillLoader class (from `agents/s05_skill_loading.py`,\nlines 51-97):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\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\"\n f\"{skill['body']}\\n</skill>\")\n```\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/*.md files Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s06",
|
||||
"locale": "en",
|
||||
"title": "s06: Compact",
|
||||
"content": "# s06: Compact\n\n> A three-layer compression pipeline lets the agent work indefinitely by strategically forgetting old tool results, auto-summarizing when tokens exceed a threshold, and allowing manual compression on demand.\n\n## The Problem\n\nThe context window is finite. After enough tool calls, the messages array\nexceeds the model's context limit and the API call fails. Even before\nhitting the hard limit, performance degrades: the model becomes slower,\nless accurate, and starts ignoring earlier messages.\n\nA 200,000 token context window sounds large, but a single `read_file` on\na 1000-line source file consumes ~4000 tokens. After reading 30 files and\nrunning 20 bash commands, you are at 100,000+ tokens. The agent cannot\nwork on large codebases without some form of compression.\n\nThe three-layer pipeline addresses this with increasing aggressiveness:\nLayer 1 (micro-compact) silently replaces old tool results every turn.\nLayer 2 (auto-compact) triggers a full summarization when tokens exceed\na threshold. Layer 3 (manual compact) lets the model trigger compression\nitself.\n\nTeaching simplification: the token estimation here uses a rough\ncharacters/4 heuristic. Production systems use proper tokenizer\nlibraries for accurate counts.\n\n## The Solution\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, find all\n tool_result entries older than the last 3 and replace their content.\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 to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **Layer 2 -- auto_compact**: When estimated tokens exceed 50,000,\n save the full transcript and ask the LLM to summarize.\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\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 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 summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **Layer 3 -- manual compact**: The `compact` tool triggers the same\n summarization on demand.\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. The agent loop integrates all three layers.\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n## Key Code\n\nThe three-layer pipeline (from `agents/s06_context_compact.py`,\nlines 67-93 and 189-223):\n\n```python\nTHRESHOLD = 50000\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n # Replace old tool results with placeholders\n ...\n\ndef auto_compact(messages):\n # Save transcript, LLM summarize, replace messages\n ...\n\ndef agent_loop(messages):\n while True:\n micro_compact(messages) # Layer 1\n Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s07",
|
||||
"locale": "en",
|
||||
"title": "s07: Tasks",
|
||||
"content": "# s07: Tasks\n\n> Tasks persist as JSON files on the filesystem with a dependency graph, so they survive context compression and can be shared across agents.\n\n## The Problem\n\nIn-memory state like TodoManager (s03) is lost when the context is\ncompressed (s06). After auto_compact replaces messages with a summary,\nthe todo list is gone. The agent has to reconstruct it from the summary\ntext, which is lossy and error-prone.\n\nThis is the critical s06-to-s07 bridge: TodoManager items die with\ncompression; file-based tasks don't. Moving state to the filesystem\nmakes it compression-proof.\n\nMore fundamentally, in-memory state is invisible to other agents.\nWhen we eventually build teams (s09+), teammates need a shared task\nboard. In-memory data structures are process-local.\n\nThe solution is to persist tasks as JSON files in `.tasks/`. Each task\nis a separate file with an ID, subject, status, and dependency graph.\nCompleting task 1 automatically unblocks task 2 if task 2 has\n`blockedBy: [1]`. The file system becomes the source of truth.\n\n## The Solution\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\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## How It Works\n\n1. The TaskManager provides CRUD operations. Each task is a JSON file.\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. When a task is marked completed, `_clear_dependency` removes its ID\n from all other tasks' `blockedBy` lists.\n\n```python\ndef _clear_dependency(self, completed_id: int):\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. The `update` method handles status changes and bidirectional dependency\n wiring.\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 if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\n self._save(task)\n```\n\n4. Four task tools are added to 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\"],\n 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## Key Code\n\nThe TaskManager with dependency graph (from `agents/s07_task_system.py`,\nlines 46-123):\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 _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\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 def _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\n## What Changed From s06\n\n| Component | BeLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s08",
|
||||
"locale": "en",
|
||||
"title": "s08: Background Tasks",
|
||||
"content": "# s08: Background Tasks\n\n> A BackgroundManager runs commands in separate threads and drains a notification queue before each LLM call, so the agent never blocks on long-running operations.\n\n## The Problem\n\nSome commands take minutes: `npm install`, `pytest`, `docker build`. With\na blocking agent loop, the model sits idle waiting for the subprocess to\nfinish. It cannot do anything else. If the user asked \"install dependencies\nand while that runs, create the config file,\" the agent would install\nfirst, _then_ create the config -- sequentially, not in parallel.\n\nThe agent needs concurrency. Not full multi-threading of the agent loop\nitself, but the ability to fire off a long command and continue working\nwhile it runs. When the command finishes, its result should appear\nnaturally in the conversation.\n\nThe solution is a BackgroundManager that runs commands in daemon threads\nand collects results in a notification queue. Before each LLM call, the\nqueue is drained and results are injected into the messages.\n\n## The Solution\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\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 +-- notification queue --+\n |\n [results injected before\n next LLM call]\n```\n\n## How It Works\n\n1. The BackgroundManager tracks tasks and maintains a thread-safe\n 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 a task_id immediately.\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. The thread target `_execute` runs the subprocess and pushes\n results to the notification queue.\n\n```python\ndef _execute(self, task_id: str, command: str):\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 status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()` returns and clears pending results.\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. 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 and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\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## Key Code\n\nThe BackgroundManager (from `agents/s08_background_tasks.py`, lines 49-107):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\",\n \"result\": None,\n \"command\": command}\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command), daemon=True)\n thread.start()\n return f\"BackgrounLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s09",
|
||||
"locale": "en",
|
||||
"title": "s09: Agent Teams",
|
||||
"content": "# s09: Agent Teams\n\n> Persistent teammates with JSONL inboxes turn isolated agents into a communicating team -- spawn, message, broadcast, and drain.\n\n## The Problem\n\nSubagents (s04) are disposable: spawn, work, return summary, die. They\nhave no identity, no memory between invocations, and no way to receive\nfollow-up instructions. Background tasks (s08) run shell commands but\ncannot make LLM-guided decisions or communicate findings.\n\nFor real teamwork you need three things: (1) persistent agents that\nsurvive beyond a single prompt, (2) identity and lifecycle management,\nand (3) a communication channel between agents. Without messaging, even\npersistent teammates are deaf and mute -- they can work in parallel but\nnever coordinate.\n\nThe solution combines a TeammateManager for spawning persistent named\nagents with a MessageBus using JSONL inbox files. Each teammate runs\nits own agent loop in a thread, checks its inbox before every LLM call,\nand can send messages to any other teammate or the lead.\n\nNote on the s06-to-s07 bridge: TodoManager items from s03 die with\ncompression (s06). File-based tasks (s07) survive compression because\nthey live on disk. Teams build on this same principle -- config.json and\ninbox files persist outside the context window.\n\n## The 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\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\n```\n\n## How It Works\n\n1. The TeammateManager maintains config.json with the team roster.\n Each member has a name, role, and status.\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 Re-spawning an idle teammate reactivates it.\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\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 self.threads[name] = thread\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. The MessageBus handles JSONL inbox files. `send()` appends a JSON\n line; `read_inbox()` reads all lines and drains the file.\n\n```python\nclass MessageBus:\n def send(self, sender, to, content,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"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 return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists():\n return \"[]\"\n msgs = [json.loads(l)\n for l in path.read_text().strip().splitlines()\n 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 and injects\n received messages into the conversation context.\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n sys_prompt = f\"You are '{name}', role: {role}, at {WORKDIR}.\"\n messages = [{\"role\": \"user\", Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s10",
|
||||
"locale": "en",
|
||||
"title": "s10: Team Protocols",
|
||||
"content": "# s10: Team Protocols\n\n> The same request_id handshake pattern powers both shutdown and plan approval -- one FSM, two applications.\n\n## The Problem\n\nIn s09, teammates work and communicate but there is no structured\ncoordination. Two problems arise:\n\n**Shutdown**: How do you stop a teammate cleanly? Killing the thread\nleaves files partially written and config.json in a wrong state.\nGraceful shutdown requires a handshake: the lead requests, the teammate\ndecides whether to approve (finish and exit) or reject (keep working).\n\n**Plan approval**: How do you gate execution? When the lead says\n\"refactor the auth module,\" the teammate starts immediately. For\nhigh-risk changes, the lead should review the plan before execution\nbegins. A junior proposes, a senior approves.\n\nBoth problems share the same structure: one side sends a request with a\nunique ID, the other side responds referencing that ID. A finite state\nmachine tracks each request through pending -> approved | rejected.\n\n## The 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 | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\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\n a shutdown_request 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] = {\n \"target\": teammate, \"status\": \"pending\",\n }\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 in its inbox and calls the\n `shutdown_response` tool to approve or reject.\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. The teammate loop checks for approved shutdown and exits.\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. Plan approval follows the identical pattern. The teammate submits\n a plan, generating a request_id.\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. The lead reviews and responds with the same request_id.\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{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,\n \"approve\": approve,\n \"feedback\": feedback})\n return f\"Plan {req['status']} for '{req['from']}'\"\n```\n\n6. Both protocols use the same `plan_approval` tool name with two\n modes: teammates submit (no request_id), the lead reviews (with\n request_id).\n\n```python\n# Lead tool dispatch:\n\"plan_approval\": lambda **kw: handle_plan_review(\n kw[\"request_id\"], kw[\"approve\"],\n kw.get(\"feedback\", \"\")),\n# Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s11",
|
||||
"locale": "en",
|
||||
"title": "s11: Autonomous Agents",
|
||||
"content": "# s11: Autonomous Agents\n\n> An idle cycle with task board polling lets teammates find and claim work themselves, with identity re-injection after context compression.\n\n## The Problem\n\nIn s09-s10, teammates only work when explicitly told to. The lead must\nspawn each teammate with a specific prompt. If the task board has 10\nunclaimed tasks, the lead must manually assign each one. This does not\nscale.\n\nTrue autonomy means teammates find work themselves. When a teammate\nfinishes its current task, it should scan the task board for unclaimed\nwork, claim a task, and start working -- without any instruction from\nthe lead.\n\nBut autonomous agents face a subtlety: after context compression, the\nagent might forget who it is. If the messages are summarized, the\noriginal system prompt identity (\"you are alice, role: coder\") fades.\nIdentity re-injection solves this by inserting an identity block at the\nstart of compressed contexts.\n\nTeaching simplification: the token estimation used here is rough\n(characters / 4). Production systems use proper tokenizer libraries.\nThe nag threshold of 3 rounds (from s03) is set low for teaching\nvisibility; production agents typically use a higher threshold around 10.\n\n## The 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\n | (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 \"You are 'alice', role: coder, team: my-team\"\n```\n\n## How It Works\n\n1. The teammate loop has two phases: WORK and IDLE. WORK runs the\n standard agent loop. When the LLM stops calling tools (or calls\n the `idle` tool), the teammate enters the IDLE phase.\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 inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\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 the inbox and task board in a loop.\n\n```python\ndef _idle_poll(self, name, messages):\n polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. Task board scanning looks for pending, unowned, unblocked tasks.\n\n```python\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\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\ndef claim_task(task_id: int, owner: str):\n path = TASKS_DIR / f\"task_{task_id}.json\"\n task = json.loads(path.read_text())\n task[\"status\"] = \"in_progress\"\n task[\"owner\"] = owner\n path.write_text(json.dumps(task, indent=2))\n```\n\n4. Identity re-injection inserts an identity block when the context\n is too short, indicating compression has occurred.\n\n```python\ndef make_identity_block(name, role, team_name):\n return {\"role\": \"user\",\n Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s01",
|
||||
"locale": "zh",
|
||||
"title": "s01: Agent Loop (智能体循环)",
|
||||
"content": "# s01: Agent Loop (智能体循环)\n\n> AI 编程智能体的全部秘密就是一个 while 循环 -- 把工具执行结果反馈给模型, 直到模型决定停止。\n\n## 问题\n\n为什么语言模型不能直接回答编程问题? 因为编程需要**与真实世界交互**。模型需要读取文件、运行测试、检查错误、反复迭代。单次的提示-响应交互无法做到这些。\n\n没有 agent loop, 你就得手动把输出复制粘贴回模型。用户自己变成了那个循环。Agent loop 将这个过程自动化: 调用模型, 执行它要求的工具, 把结果送回去, 重复 -- 直到模型说 \"我完成了\"。\n\n考虑一个简单任务: \"创建一个打印 hello 的 Python 文件。\" 模型需要 (1) 决定写文件, (2) 写入文件, (3) 验证是否正常工作。至少三次工具调用。没有循环的话, 每一次都需要人工干预。\n\n## 解决方案\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## 工作原理\n\n1. 用户提供一个 prompt, 成为第一条消息。\n\n```python\nhistory.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. 助手的响应被追加到消息列表中。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. 检查 stop_reason。如果模型没有调用工具, 循环结束。这是唯一的退出条件。\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. 对响应中的每个 tool_use 块, 执行工具 (本节课中是 bash) 并收集结果。\n\n```python\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 })\n```\n\n6. 结果作为 user 消息追加, 循环继续。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## 核心代码\n\n最小可行智能体 -- 不到 30 行代码实现整个模式\n(来自 `agents/s01_agent_loop.py`, 第 66-86 行):\n\n```python\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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## 变更内容\n\n这是第 1 节课 -- 起点。没有前置课程。\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这个循环是所有基于 LLM 的智能体的通用基础。生产实现会增加错误处理、token 计数、流式输出和重试逻辑, 但基本结构不变。简洁性就是重点: 一个退出条件 (`stop_reason != \"tool_use\"`) 控制整个流程。本课程中的所有其他内容 -- 工具、规划、压缩、团队 -- 都是在这个循环之上叠加, 而不修改它。理解这个循环就是理解所有智能体。\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\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"
|
||||
},
|
||||
{
|
||||
"version": "s02",
|
||||
"locale": "zh",
|
||||
"title": "s02: Tools (工具)",
|
||||
"content": "# s02: Tools (工具)\n\n> 一个分发映射表 (dispatch map) 将工具调用路由到处理函数 -- 循环本身完全不需要改动。\n\n## 问题\n\n只有 `bash` 时, 智能体所有操作都通过 shell: 读文件、写文件、编辑文件。这能用但很脆弱。`cat` 的输出会被不可预测地截断。`sed` 替换遇到特殊字符就会失败。模型浪费大量 token 构造 shell 管道, 而一个直接的函数调用会简单得多。\n\n更重要的是, bash 是一个安全攻击面。每次 bash 调用都能做 shell 能做的一切。有了专用工具如 `read_file` 和 `write_file`, 你可以在工具层面强制路径沙箱化, 阻止危险模式, 而不是寄希望于模型自觉回避。\n\n关键洞察: 添加工具不需要修改循环。s01 的循环保持不变。你只需在工具数组中添加条目, 编写处理函数, 然后通过 dispatch map 把它们关联起来。\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. 为每个工具定义处理函数。每个函数接受与工具 input_schema 对应的关键字参数, 返回字符串结果。\n\n```python\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. 在 agent loop 中, 按名称查找处理函数, 而不是硬编码。\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)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. 路径沙箱化防止模型逃逸出工作区。\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```\n\n## 核心代码\n\ndispatch 模式 (来自 `agents/s02_tool_use.py`, 第 93-129 行):\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\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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) 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 messages.append({\"role\": \"user\", \"content\": results})\n```\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\ndispatch map 模式可以线Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s03",
|
||||
"locale": "zh",
|
||||
"title": "s03: TodoWrite (待办写入)",
|
||||
"content": "# s03: TodoWrite (待办写入)\n\n> TodoManager 让智能体能追踪自己的进度, 而 nag reminder 注入机制在它忘记更新时强制提醒。\n\n## 问题\n\n当智能体处理多步骤任务时, 它经常丢失对已完成和待办事项的追踪。没有显式的计划, 模型可能重复工作、跳过步骤或跑偏。用户也无法看到智能体内部的计划。\n\n这个问题比听起来更严重。长对话会导致模型 \"漂移\" -- 随着上下文窗口被工具结果填满, 系统提示的影响力逐渐减弱。一个 10 步的重构任务可能完成了 1-3 步, 然后模型就开始即兴发挥, 因为它忘了第 4-10 步的存在。\n\n解决方案是结构化状态: 一个模型显式写入的 TodoManager。模型创建计划, 工作时将项目标记为 in_progress, 完成后标记为 completed。nag reminder 机制在模型连续 3 轮以上不更新待办时注入提醒。\n\n教学简化说明: 这里 nag 阈值设为 3 轮是为了教学可见性。生产环境的智能体通常使用约 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 __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\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 \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nag reminder 在模型连续 3 轮以上不调用 `todo` 时, 向 tool_result 消息中注入 `<reminder>` 标签。\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. 系统提示指导模型使用 todo 进行规划。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## 核心代码\n\nTodoManager 和 nag 注入 (来自 `agents/s03_todo_write.py`,\n第 51-85 行和第 158-187 行):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one in_progress\")\n self.items = validated\n return self.render()\n\n# In agent_loop:\nif rounds_since_todo >= 3:\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|----------------|------------------|--------------------------|\n| Tools Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s04",
|
||||
"locale": "zh",
|
||||
"title": "s04: Subagent (子智能体)",
|
||||
"content": "# s04: Subagent (子智能体)\n\n> 子智能体使用全新的消息列表运行, 与父智能体共享文件系统, 仅返回摘要 -- 保持父上下文的整洁。\n\n## 问题\n\n随着智能体工作, 它的消息数组不断增长。每次工具调用、每次文件读取、每次 bash 输出都在累积。20-30 次工具调用后, 上下文窗口充满了无关的历史。为了回答一个简单问题而读取的 500 行文件, 会永久占据上下文中的 500 行空间。\n\n这对探索性任务尤其糟糕。\"这个项目用了什么测试框架?\" 可能需要读取 5 个文件, 但父智能体的历史中并不需要这 5 个文件的全部内容 -- 它只需要答案: \"pytest, 使用 conftest.py 配置。\"\n\n解决方案是进程隔离: 以 `messages=[]` 启动一个子智能体。子智能体进行探索、读取文件、运行命令。完成后, 只有最终的文本响应返回给父智能体。子智能体的全部消息历史被丢弃。\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.\nSubagent 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\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. 子智能体以全新的消息列表启动, 仅包含委派的 prompt。它共享相同的文件系统。\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({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. 只有最终文本返回给父智能体。子智能体 30+ 次工具调用的历史被丢弃。\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. 父智能体将此摘要作为普通的 tool_result 接收。\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## 核心代码\n\n子智能体函数 (来自 `agents/s04_subagent.py`, 第 110-128 行):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\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## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (基础) + task (仅父端) |\n| 上下文 | 单一共享 | 父 + 子隔离 |\n| Subagent | 无 | `run_subagent()` 函数 |\n| 返回值 | 不适用 | 仅摘Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s05",
|
||||
"locale": "zh",
|
||||
"title": "s05: Skills (技能加载)",
|
||||
"content": "# s05: Skills (技能加载)\n\n> 两层技能注入避免了系统提示膨胀: 在系统提示中放技能名称 (低成本), 在 tool_result 中按需放入完整技能内容。\n\n## 问题\n\n你希望智能体针对不同领域遵循特定的工作流: git 约定、测试模式、代码审查清单。简单粗暴的做法是把所有内容都塞进系统提示。但系统提示的有效注意力是有限的 -- 文本太多, 模型就会开始忽略其中一部分。\n\n如果你有 10 个技能, 每个 2000 token, 那就是 20,000 token 的系统提示。模型关注开头和结尾, 但会略过中间部分。更糟糕的是, 这些技能中大部分与当前任务无关。文件编辑任务不需要 git 工作流说明。\n\n两层方案解决了这个问题: 第一层在系统提示中放入简短的技能描述 (每个技能约 100 token)。第二层只在模型调用 `load_skill` 时, 才将完整的技能内容加载到 tool_result 中。模型知道有哪些技能可用 (低成本), 按需加载它们 (只在相关时)。\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| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## 工作原理\n\n1. 技能文件以 Markdown 格式存放在 `.skills/` 目录中, 带有 YAML frontmatter。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoader 解析 frontmatter, 分离元数据和正文。\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. 第一层: `get_descriptions()` 返回简短描述, 用于系统提示。\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. 第二层: `get_content()` 返回用 `<skill>` 标签包裹的完整正文。\n\n```python\ndef 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\n5. `load_skill` 工具只是 dispatch map 中的又一个条目。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## 核心代码\n\nSkillLoader 类 (来自 `agents/s05_skill_loading.py`, 第 51-97 行):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\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\"\n f\"{skill['body']}\\n</skill>\")\n```\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |\n| 系统提示 | 静态字符串 | + 技能描述列表 |\n| 知识库 | 无 | .skills/*.md 文件 Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s06",
|
||||
"locale": "zh",
|
||||
"title": "s06: Compact (上下文压缩)",
|
||||
"content": "# s06: Compact (上下文压缩)\n\n> 三层压缩管道让智能体可以无限期工作: 策略性地遗忘旧的工具结果, token 超过阈值时自动摘要, 以及支持手动触发压缩。\n\n## 问题\n\n上下文窗口是有限的。工具调用积累到足够多时, 消息数组会超过模型的上下文限制, API 调用直接失败。即使在到达硬限制之前, 性能也会下降: 模型变慢、准确率降低, 开始忽略早期消息。\n\n200,000 token 的上下文窗口听起来很大, 但一次 `read_file` 读取 1000 行源文件就消耗约 4000 token。读取 30 个文件、运行 20 条 bash 命令后, 你就已经用掉 100,000+ token 了。没有某种压缩机制, 智能体无法在大型代码库上工作。\n\n三层管道以递增的激进程度来应对这个问题:\n第一层 (micro-compact) 每轮静默替换旧的工具结果。\n第二层 (auto-compact) 在 token 超过阈值时触发完整摘要。\n第三层 (manual compact) 让模型自己触发压缩。\n\n教学简化说明: 这里的 token 估算使用粗略的 字符数/4 启发式方法。生产系统使用专业的 tokenizer 库进行精确计数。\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 调用前, 找到最近 3 条之前的所有 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 to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第二层 -- auto_compact**: 当估算 token 超过 50,000 时, 保存完整对话记录并请求 LLM 进行摘要。\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\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 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 summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第三层 -- manual compact**: `compact` 工具按需触发相同的摘要机制。\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. Agent loop 整合了全部三层。\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n## 核心代码\n\n三层管道 (来自 `agents/s06_context_compact.py`, 第 67-93 行和第 189-223 行):\n\n```python\nTHRESHOLD = 50000\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n # Replace old tool results with placeholders\n ...\n\ndef auto_compact(messages):\n # Save transcript, LLM summarize, replace messages\n ...\n\ndef agent_loop(messages):\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.Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s07",
|
||||
"locale": "zh",
|
||||
"title": "s07: Tasks (任务系统)",
|
||||
"content": "# s07: Tasks (任务系统)\n\n> 任务以 JSON 文件形式持久化在文件系统上, 带有依赖图, 因此它们能在上下文压缩后存活, 也可以跨智能体共享。\n\n## 问题\n\n内存中的状态 (如 s03 的 TodoManager) 在上下文压缩 (s06) 时会丢失。auto_compact 用摘要替换消息后, 待办列表就没了。智能体只能从摘要文本中重建它, 这是有损且容易出错的。\n\n这就是 s06 到 s07 的关键桥梁: TodoManager 的条目随压缩消亡; 基于文件的任务不会。将状态移到文件系统上使其不受压缩影响。\n\n更根本地说, 内存中的状态对其他智能体不可见。当我们最终构建团队 (s09+) 时, 队友需要一个共享的任务看板。内存中的数据结构是进程局部的。\n\n解决方案是将任务作为 JSON 文件持久化在 `.tasks/` 目录中。每个任务是一个单独的文件, 包含 ID、主题、状态和依赖图。完成任务 1 会自动解除任务 2 的阻塞 (如果任务 2 有 `blockedBy: [1]`)。文件系统成为唯一的真实来源。\n\n## 解决方案\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\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 工作原理\n\n1. TaskManager 提供 CRUD 操作。每个任务是一个 JSON 文件。\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. 当任务标记为 completed 时, `_clear_dependency` 将其 ID 从所有其他任务的 `blockedBy` 列表中移除。\n\n```python\ndef _clear_dependency(self, completed_id: int):\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 if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\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\"],\n 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## 核心代码\n\n带依赖图的 TaskManager (来自 `agents/s07_task_system.py`, 第 46-123 行):\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 _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\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 def _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\n## 相对 s06 的变更\n\n| 组件 | 之前Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s08",
|
||||
"locale": "zh",
|
||||
"title": "s08: Background Tasks (后台任务)",
|
||||
"content": "# s08: Background Tasks (后台任务)\n\n> BackgroundManager 在独立线程中运行命令, 在每次 LLM 调用前排空通知队列, 使智能体永远不会因长时间运行的操作而阻塞。\n\n## 问题\n\n有些命令需要几分钟: `npm install`、`pytest`、`docker build`。在阻塞式的 agent loop 中, 模型只能干等子进程结束, 什么也做不了。如果用户要求 \"安装依赖, 同时创建配置文件\", 智能体会先安装, 然后才创建配置 -- 串行执行, 而非并行。\n\n智能体需要并发能力。不是将 agent loop 本身完全多线程化, 而是能够发起一个长时间命令然后继续工作。当命令完成时, 结果自然地出现在对话中。\n\n解决方案是一个 BackgroundManager, 它在守护线程中运行命令, 将结果收集到通知队列中。每次 LLM 调用前, 队列被排空, 结果注入到消息中。\n\n## 解决方案\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\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 +-- notification queue --+\n |\n [results injected before\n 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()` 启动一个守护线程并立即返回 task_id。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. 线程目标函数 `_execute` 运行子进程并将结果推入通知队列。\n\n```python\ndef _execute(self, task_id: str, command: str):\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 status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()` 返回并清空待处理的结果。\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. Agent loop 在每次 LLM 调用前排空通知。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\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## 核心代码\n\nBackgroundManager (来自 `agents/s08_background_tasks.py`, 第 49-107 行):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\",\n \"result\": None,\n \"command\": command}\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n\n def _execute(self, task_id, command):\n # run subprocess, push to queue\n ...\n\n def drain_notifications(self) -> list:\n Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s09",
|
||||
"locale": "zh",
|
||||
"title": "s09: Agent Teams (智能体团队)",
|
||||
"content": "# s09: Agent Teams (智能体团队)\n\n> 持久化的队友通过 JSONL 收件箱将孤立的智能体转变为可通信的团队 -- spawn、message、broadcast 和 drain。\n\n## 问题\n\n子智能体 (s04) 是一次性的: 生成、工作、返回摘要、消亡。它们没有身份, 没有跨调用的记忆, 也无法接收后续指令。后台任务 (s08) 运行 shell 命令, 但不能做 LLM 引导的决策或交流发现。\n\n真正的团队协作需要三样东西: (1) 存活时间超过单次 prompt 的持久化智能体, (2) 身份和生命周期管理, (3) 智能体之间的通信通道。没有消息机制, 即使持久化的队友也是又聋又哑的 -- 它们可以并行工作但永远无法协调。\n\n解决方案将 TeammateManager (用于生成持久化的命名智能体) 与使用 JSONL 收件箱文件的 MessageBus 结合。每个队友在独立线程中运行自己的 agent loop, 每次 LLM 调用前检查收件箱, 可以向任何其他队友或领导发送消息。\n\n关于 s06 到 s07 的桥梁: s03 的 TodoManager 条目随压缩 (s06) 消亡。基于文件的任务 (s07) 因为存储在磁盘上而能存活压缩。团队建立在同样的原则上 -- config.json 和收件箱文件持久化在上下文窗口之外。\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\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\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。重新 spawn 一个 idle 状态的队友会将其重新激活。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\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 self.threads[name] = thread\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,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"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 return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists():\n return \"[]\"\n msgs = [json.loads(l)\n for l in path.read_text().strip().splitlines()\n 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 sys_prompt = f\"You are '{name}', role: {role}, at {WORKDIR}.\"\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inboLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s10",
|
||||
"locale": "zh",
|
||||
"title": "s10: Team Protocols (团队协议)",
|
||||
"content": "# s10: Team Protocols (团队协议)\n\n> 同一个 request_id 握手模式驱动了关机和计划审批两种协议 -- 一个 FSM, 两种应用。\n\n## 问题\n\n在 s09 中, 队友可以工作和通信, 但没有结构化的协调。出现了两个问题:\n\n**关机**: 如何干净地停止一个队友? 直接杀线程会留下写了一半的文件和错误状态的 config.json。优雅关机需要握手: 领导发起请求, 队友决定是批准 (完成并退出) 还是拒绝 (继续工作)。\n\n**计划审批**: 如何控制执行门槛? 当领导说 \"重构认证模块\", 队友会立即开始。对于高风险变更, 领导应该在执行开始前审查计划。初级提出方案, 高级批准。\n\n两个问题共享相同的结构: 一方发送带唯一 ID 的请求, 另一方引用该 ID 作出响应。一个有限状态机 (FSM) 跟踪每个请求经历 pending -> approved | rejected 的状态变迁。\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 | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\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 并通过收件箱发送 shutdown_request 来发起关机。\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] = {\n \"target\": teammate, \"status\": \"pending\",\n }\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. 队友在收件箱中收到请求, 调用 `shutdown_response` 工具来批准或拒绝。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. 队友的循环检查是否批准了关机并退出。\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. 计划审批遵循完全相同的模式。队友提交计划时生成一个 request_id。\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. 领导审查后使用同一个 request_id 作出响应。\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{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,\n \"approve\": approve,\n \"feedback\": feedback})\n return f\"Plan {req['status']} for '{req['from']}'\"\n```\n\n6. 两个协议使用同一个 `plan_approval` 工具名, 有两种模式: 队友提交 (无 request_id), 领导审查 (带 request_id)。\n\n```python\n# Lead tool dispatch:\n\"plan_approval\": lambda **kw: handle_plan_review(\n kw[\"request_id\"], kw[\"approve\"],\n kw.get(\"feedback\", \"\")),\n# Teammate: submit mode (generate request_id)\n```\n\n## 核心代码\n\n双协议处理器 (来自 `agents/s10_team_protocols.py`):\n\n```pythoLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s11",
|
||||
"locale": "zh",
|
||||
"title": "s11: Autonomous Agents (自治智能体)",
|
||||
"content": "# s11: Autonomous Agents (自治智能体)\n\n> 带任务看板轮询的空闲循环让队友能自己发现和认领工作, 上下文压缩后通过身份重注入保持角色认知。\n\n## 问题\n\n在 s09-s10 中, 队友只在被明确指示时才工作。领导必须用特定的 prompt 生成每个队友。如果任务看板上有 10 个未认领的任务, 领导必须手动分配每一个。这无法扩展。\n\n真正的自治意味着队友自己寻找工作。当一个队友完成当前任务后, 它应该扫描任务看板寻找未认领的工作, 认领一个任务, 然后开始工作 -- 不需要领导的任何指令。\n\n但自治智能体面临一个微妙问题: 上下文压缩后, 智能体可能忘记自己是谁。如果消息被摘要化, 原始系统提示中的身份 (\"你是 alice, 角色: coder\") 就会淡化。身份重注入通过在压缩后的上下文开头插入身份块来解决这个问题。\n\n教学简化说明: 这里的 token 估算比较粗糙 (字符数 / 4)。生产系统使用专业的 tokenizer 库。s03 中的 nag 阈值 3 轮是为教学可见性设的低值; 生产环境的智能体通常使用约 10 轮的阈值。\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\n | (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 \"You are 'alice', role: coder, team: my-team\"\n```\n\n## 工作原理\n\n1. 队友循环有两个阶段: WORK 和 IDLE。WORK 阶段运行标准的 agent loop。当 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 inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\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 polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['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 TASKS_DIR.mkdir(exist_ok=True)\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\ndef claim_task(task_id: int, owner: str):\n path = TASKS_DIR / f\"task_{task_id}.json\"\n task = json.loads(path.read_text())\n task[\"status\"] = \"in_progress\"\n task[\"owner\"] = owner\n path.write_text(json.dumps(task, indent=2))\n```\n\n4. 身份重注入: 当上下文过短时插入身份块, 表明发生了压缩。\n\n```python\ndef make_identity_block(name, role, team_name):\n return {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', \"\n f\"role: {role}, team: Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s01",
|
||||
"locale": "ja",
|
||||
"title": "s01: The Agent Loop",
|
||||
"content": "# s01: The Agent Loop\n\n> AIコーディングエージェントの秘密はすべて、モデルが「終了」と判断するまでツール結果をモデルにフィードバックし続けるwhileループにある。\n\n## 問題\n\nなぜ言語モデルは単体でコーディングの質問に答えられないのか。それはコーディングが「現実世界とのインタラクション」を必要とするからだ。モデルはファイルを読み、テストを実行し、エラーを確認し、反復する必要がある。一回のプロンプト-レスポンスのやり取りではこれは実現できない。\n\nagent loopがなければ、ユーザーが自分でモデルの出力をコピーペーストして戻す必要がある。つまりユーザー自身がループの役割を果たすことになる。agent loopはこれを自動化する: モデルを呼び出し、モデルが要求したツールを実行し、結果をフィードバックし、モデルが「完了」と言うまで繰り返す。\n\n単純なタスクを考えてみよう: 「helloと出力するPythonファイルを作成せよ」。モデルは(1)ファイルを書くことを決定し、(2)書き、(3)動作を検証する必要がある。最低でも3回のツール呼び出しが必要だ。ループがなければ、そのたびに手動の介入が必要になる。\n\n## 解決策\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## 仕組み\n\n1. ユーザーがプロンプトを入力する。これが最初のメッセージになる。\n\n```python\nhistory.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. アシスタントのレスポンスがメッセージに追加される。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. stop reasonを確認する。モデルがツールを呼び出さなかった場合、ループは終了する。これが唯一の終了条件だ。\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. レスポンス中の各tool_useブロックについて、ツール(このセッションではbash)を実行し、結果を収集する。\n\n```python\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 })\n```\n\n6. 結果がuserメッセージとして追加され、ループが続行する。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## 主要コード\n\n最小限のエージェント -- パターン全体が30行未満\n(`agents/s01_agent_loop.py` 66-86行目):\n\n```python\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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## 変更点\n\nこれはセッション1 -- 出発点である。前のセッションは存在しない。\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このループはすべてのLLMベースエージェントの普遍的な基盤だ。本番実装ではエラーハンドリング、トークンカウント、ストリーミング、リトライロジLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s02",
|
||||
"locale": "ja",
|
||||
"title": "s02: Tools",
|
||||
"content": "# s02: Tools\n\n> ディスパッチマップがツール呼び出しをハンドラ関数にルーティングする -- ループ自体はまったく変更しない。\n\n## 問題\n\n`bash`だけでは、エージェントはすべてをシェル経由で行う: ファイルの読み取り、書き込み、編集。これは動くが脆弱だ。`cat`の出力は予期しないタイミングで切り詰められる。`sed`による置換は特殊文字で失敗する。直接的な関数呼び出しの方がシンプルなのに、モデルはシェルパイプラインの構築にトークンを浪費する。\n\nさらに重要なのは、bashがセキュリティ上の攻撃面であること。bashの呼び出しはシェルでできることなら何でもできてしまう。`read_file`や`write_file`のような専用ツールがあれば、モデルが危険な操作を避けることを期待するのではなく、ツールレベルでパスのサンドボックス化や危険なパターンのブロックを強制できる。\n\n重要な洞察は、ツールを追加してもループを変更する必要がないということだ。s01のループはそのまま同一で維持される。ツール配列にエントリを追加し、ハンドラ関数を追加し、ディスパッチマップで接続するだけだ。\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. 各ツールのハンドラ関数を定義する。各関数はツールのinput_schemaに対応するキーワード引数を受け取り、文字列の結果を返す。\n\n```python\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. agent loop内で、ハードコードの代わりに名前でハンドラをルックアップする。\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)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. パスのサンドボックス化により、モデルがワークスペースの外に出ることを防ぐ。\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```\n\n## 主要コード\n\nディスパッチパターン(`agents/s02_tool_use.py` 93-129行目):\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\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\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) 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 Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s03",
|
||||
"locale": "ja",
|
||||
"title": "s03: TodoWrite",
|
||||
"content": "# s03: TodoWrite\n\n> TodoManagerによりエージェントが自身の進捗を追跡でき、nagリマインダーの注入により更新を忘れた場合に強制的に更新させる。\n\n## 問題\n\nエージェントがマルチステップのタスクに取り組むとき、何を完了し何が残っているかを見失うことが多い。明示的な計画がなければ、モデルは作業を繰り返したり、ステップを飛ばしたり、脱線したりする可能性がある。ユーザーにはエージェントの内部計画が見えない。\n\nこれは見た目以上に深刻だ。長い会話ではモデルが「ドリフト」する -- コンテキストウィンドウがツール結果で埋まるにつれ、システムプロンプトの影響力が薄れていく。10ステップのリファクタリングタスクでステップ1-3を完了した後、モデルはステップ4-10の存在を忘れて即興で行動し始めるかもしれない。\n\n解決策は構造化された状態管理だ: モデルが明示的に書き込むTodoManager。モデルは計画を作成し、作業中のアイテムをin_progressとしてマークし、完了時にcompletedとマークする。nagリマインダーは、モデルが3ラウンド以上todoを更新しなかった場合にナッジを注入する。\n\n教育上の簡略化: nagの閾値3ラウンドは教育目的の可視化のために低く設定されている。本番のエージェントでは過剰なプロンプトを避けるため閾値は約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`にできるのは一度に1つだけ。\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\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 \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nagリマインダーは、モデルが3ラウンド以上`todo`を呼び出さなかった場合にtool_resultメッセージに`<reminder>`タグを注入する。\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. システムプロンプトがモデルにtodoによる計画を指示する。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## 主要コード\n\nTodoManagerとnag注入(`agents/s03_todo_write.py` 51-85行目および158-187行目):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n 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({\n \"id\": iteLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s04",
|
||||
"locale": "ja",
|
||||
"title": "s04: Subagents",
|
||||
"content": "# s04: Subagents\n\n> サブエージェントは新しいメッセージリストで実行され、親とファイルシステムを共有し、要約のみを返す -- 親のコンテキストをクリーンに保つ。\n\n## 問題\n\nエージェントが作業するにつれ、メッセージ配列は膨張する。すべてのツール呼び出し、ファイル読み取り、bash出力が蓄積されていく。20-30回のツール呼び出しの後、コンテキストウィンドウは無関係な履歴で溢れる。ちょっとした質問に答えるために500行のファイルを読むと、永久に500行がコンテキストに追加される。\n\nこれは探索的タスクで特に深刻だ。「このプロジェクトはどのテストフレームワークを使っているか」という質問には5つのファイルを読む必要があるかもしれないが、親エージェントには5つのファイルの内容すべては不要だ -- 「pytest with conftest.py configuration」という回答だけが必要なのだ。\n\n解決策はプロセスの分離だ: `messages=[]`で子エージェントを生成する。子は探索し、ファイルを読み、コマンドを実行する。終了時には最終的なテキストレスポンスだけが親に返される。子のメッセージ履歴全体は破棄される。\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.\nSubagent 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\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. サブエージェントは委譲されたプロンプトのみを含む新しいメッセージリストで開始する。ファイルシステムは共有される。\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({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. 最終テキストのみが親に返される。子の30回以上のツール呼び出し履歴は破棄される。\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. 親はこの要約を通常のtool_resultとして受け取る。\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## 主要コード\n\nサブエージェント関数(`agents/s04_subagent.py` 110-128行目):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\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 Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s05",
|
||||
"locale": "ja",
|
||||
"title": "s05: Skills",
|
||||
"content": "# s05: Skills\n\n> 2層のスキル注入により、スキル名をシステムプロンプトに(低コスト)、スキル本体をtool_resultに(オンデマンド)配置することで、システムプロンプトの肥大化を回避する。\n\n## 問題\n\nエージェントに特定のドメインのワークフローを遵守させたい: gitの規約、テストパターン、コードレビューのチェックリストなど。単純なアプローチはすべてをシステムプロンプトに入れることだ。しかしシステムプロンプトの実効的な注意力は有限であり、テキストが多すぎるとモデルはその一部を無視し始める。\n\n10個のスキルが各2000トークンあれば、20,000トークンのシステムプロンプトになる。モデルは先頭と末尾に注意を払い、中間部分は飛ばし読みする。さらに悪いことに、ほとんどのスキルは任意のタスクに対して無関係だ。ファイル編集のタスクにgitワークフローの指示は不要だ。\n\n2層アプローチがこれを解決する: 第1層はシステムプロンプトにスキルの短い説明を置く(スキルあたり約100トークン)。第2層はモデルが`load_skill`を呼び出した時だけ、スキル本体の全文をtool_resultに読み込む。モデルはどのスキルが存在するかを知り(低コスト)、必要な時だけ読み込む(関連する時のみ)。\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| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## 仕組み\n\n1. スキルファイルは`.skills/`にYAMLフロントマター付きMarkdownとして配置される。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoaderがフロントマターを解析し、メタデータと本体を分離する。\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. 第1層: `get_descriptions()`がシステムプロンプト用の短い行を返す。\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. 第2層: `get_content()`が`<skill>`タグで囲まれた本体全文を返す。\n\n```python\ndef 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\n5. `load_skill`ツールはディスパッチマップの単なる一エントリだ。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## 主要コード\n\nSkillLoaderクラス(`agents/s05_skill_loading.py` 51-97行目):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\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 '{namLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s06",
|
||||
"locale": "ja",
|
||||
"title": "s06: Compact",
|
||||
"content": "# s06: Compact\n\n> 3層の圧縮パイプラインにより、古いツール結果の戦略的な忘却、トークンが閾値を超えた時の自動要約、オンデマンドの手動圧縮を組み合わせて、エージェントを無期限に動作可能にする。\n\n## 問題\n\nコンテキストウィンドウは有限だ。十分なツール呼び出しの後、メッセージ配列がモデルのコンテキスト上限を超え、API呼び出しが失敗する。ハード制限に達する前でも、パフォーマンスは劣化する: モデルは遅くなり、精度が落ち、以前のメッセージを無視し始める。\n\n200,000トークンのコンテキストウィンドウは大きく聞こえるが、1000行のソースファイルに対する一回の`read_file`で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン以上になる。何らかの圧縮がなければ、エージェントは大規模なコードベースで作業できない。\n\n3層のパイプラインは積極性を段階的に上げて対処する:\n第1層(micro-compact)は毎ターン静かに古いツール結果を置換する。\n第2層(auto-compact)はトークンが閾値を超えた時に完全な要約を発動する。\n第3層(manual compact)はモデル自身が圧縮をトリガーできる。\n\n教育上の簡略化: ここでのトークン推定は大まかな「文字数/4」ヒューリスティックを使用している。本番システムでは正確なカウントのために適切なトークナイザーライブラリを使用する。\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. **第1層 -- micro_compact**: 各LLM呼び出しの前に、直近3件以前のすべての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 to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第2層 -- auto_compact**: 推定トークン数が50,000を超えた時、完全なトランスクリプトを保存し、LLMに要約を依頼する。\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\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 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 summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. agent loopが3つの層すべてを統合する。\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s07",
|
||||
"locale": "ja",
|
||||
"title": "s07: Tasks",
|
||||
"content": "# s07: Tasks\n\n> タスクはファイルシステム上にJSON形式で依存グラフ付きで永続化され、コンテキスト圧縮後も生き残り、複数エージェント間で共有できる。\n\n## 問題\n\nインメモリの状態であるTodoManager(s03)は、コンテキストが圧縮(s06)されると失われる。auto_compactがメッセージを要約で置換した後、todoリストは消える。エージェントは要約テキストからそれを再構成しなければならないが、これは不正確でエラーが起きやすい。\n\nこれがs06からs07への重要な橋渡しだ: TodoManagerのアイテムは圧縮と共に死ぬが、ファイルベースのタスクは死なない。状態をファイルシステムに移すことで、圧縮に対する耐性が得られる。\n\nさらに根本的な問題として、インメモリの状態は他のエージェントからは見えない。最終的にチーム(s09以降)を構築する際、チームメイトには共有のタスクボードが必要だ。インメモリのデータ構造はプロセスローカルだ。\n\n解決策はタスクを`.tasks/`にJSON形式で永続化すること。各タスクはID、件名、ステータス、依存グラフを持つ個別のファイルだ。タスク1を完了すると、タスク2が`blockedBy: [1]`を持つ場合、自動的にタスク2のブロックが解除される。ファイルシステムが信頼できる情報源となる。\n\n## 解決策\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\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 仕組み\n\n1. TaskManagerがCRUD操作を提供する。各タスクは1つのJSONファイル。\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. タスクが完了とマークされると、`_clear_dependency`がそのIDを他のすべてのタスクの`blockedBy`リストから除去する。\n\n```python\ndef _clear_dependency(self, completed_id: int):\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 if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\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\"],\n 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## 主要コード\n\n依存グラフ付きTaskManager(`agents/s07_task_system.py` 46-123行目):\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 _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \Line truncated
|
||||
},
|
||||
{
|
||||
"version": "s08",
|
||||
"locale": "ja",
|
||||
"title": "s08: Background Tasks",
|
||||
"content": "# s08: Background Tasks\n\n> BackgroundManagerがコマンドを別スレッドで実行し、各LLM呼び出しの前に通知キューをドレインすることで、エージェントは長時間実行操作でブロックされなくなる。\n\n## 問題\n\n一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングのagent loopでは、モデルはサブプロセスの終了を待って待機する。他のことは何もできない。ユーザーが「依存関係をインストールして、その間にconfigファイルを作成して」と言った場合、エージェントはまずインストールを行い、その後configを作成する -- 並列ではなく逐次的に。\n\nエージェントには並行性が必要だ。agent loop自体の完全なマルチスレッディングではなく、長いコマンドを発射して実行中に作業を続ける能力だ。コマンドが終了したら、その結果は自然に会話に現れるべきだ。\n\n解決策は、BackgroundManagerがコマンドをデーモンスレッドで実行し、結果を通知キューに収集すること。各LLM呼び出しの前にキューがドレインされ、結果がメッセージに注入される。\n\n## 解決策\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\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 +-- notification queue --+\n |\n [results injected before\n 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()`がデーモンスレッドを開始し、task_idを即座に返す。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. スレッドのターゲットである`_execute`がサブプロセスを実行し、結果を通知キューにプッシュする。\n\n```python\ndef _execute(self, task_id: str, command: str):\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 status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()`が保留中の結果を返してクリアする。\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. agent loopが各LLM呼び出しの前に通知をドレインする。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\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## 主要コード\n\nBackgroundManager(`agents/s08_background_tasks.py` 49-107行目):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n selfLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s09",
|
||||
"locale": "ja",
|
||||
"title": "s09: Agent Teams",
|
||||
"content": "# s09: Agent Teams\n\n> JSONL形式のインボックスを持つ永続的なチームメイトが、孤立したエージェントをコミュニケーションするチームに変える -- spawn、message、broadcast、drain。\n\n## 問題\n\nサブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もなく、フォローアップの指示を受け取る方法もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定やフィードバックの伝達はできない。\n\n本物のチームワークには3つのものが必要だ: (1)単一のプロンプトを超えて存続する永続的なエージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネル。メッセージングがなければ、永続的なチームメイトでさえ聾唖だ -- 並列に作業できるが協調することはない。\n\n解決策は、名前付きの永続的エージェントを生成するTeammateManagerと、JONSLインボックスファイルを使うMessageBusの組み合わせだ。各チームメイトは自身のagent loopをスレッドで実行し、各LLM呼び出しの前にインボックスを確認し、他のチームメイトやリーダーにメッセージを送れる。\n\ns06からs07への橋渡しについての注記: s03のTodoManagerアイテムは圧縮(s06)と共に死ぬ。ファイルベースのタスク(s07)はディスク上に存在するため圧縮後も生き残る。チームも同じ原則の上に構築されている -- config.jsonとインボックスファイルはコンテキストウィンドウの外に永続化される。\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\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\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をスレッドで開始する。アイドル状態のチームメイトを再spawnすると再活性化される。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\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 self.threads[name] = thread\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,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"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 retuLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s10",
|
||||
"locale": "ja",
|
||||
"title": "s10: Team Protocols",
|
||||
"content": "# s10: Team Protocols\n\n> 同じrequest_idハンドシェイクパターンがシャットダウンとプラン承認の両方を支える -- 1つのFSM、2つの適用。\n\n## 問題\n\ns09ではチームメイトが作業しコミュニケーションするが、構造化された協調はない。2つの問題が生じる:\n\n**シャットダウン**: チームメイトをどうやってクリーンに停止するか。スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。グレースフルシャットダウンにはハンドシェイクが必要だ: リーダーが要求し、チームメイトが承認(終了処理を行い退出)するか拒否(作業を継続)するかを判断する。\n\n**プラン承認**: 実行をどうやってゲーティングするか。リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行開始前にリーダーが計画をレビューすべきだ。ジュニアが提案し、シニアが承認する。\n\n両方の問題は同じ構造を共有している: 一方がユニークなIDを持つリクエストを送り、もう一方がそのIDを参照してレスポンスする。有限状態機械が各リクエストをpending -> approved | rejectedの遷移で追跡する。\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 | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\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を生成し、インボックス経由でshutdown_requestを送信してシャットダウンを開始する。\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] = {\n \"target\": teammate, \"status\": \"pending\",\n }\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. チームメイトはインボックスでリクエストを受信し、`shutdown_response`ツールを呼び出して承認または拒否する。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. チームメイトのループが承認済みシャットダウンを確認して終了する。\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. プラン承認も同一のパターンに従う。チームメイトがプランを提出し、request_idを生成する。\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. リーダーがレビューし、同じrequest_idでレスポンスする。\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{request_id}'\"\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], fLine truncated
|
||||
},
|
||||
{
|
||||
"version": "s11",
|
||||
"locale": "ja",
|
||||
"title": "s11: Autonomous Agents",
|
||||
"content": "# s11: Autonomous Agents\n\n> タスクボードポーリング付きのアイドルサイクルにより、チームメイトが自分で作業を見つけて確保できるようになり、コンテキスト圧縮後にはアイデンティティの再注入が行われる。\n\n## 問題\n\ns09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てなければならない。これはスケールしない。\n\n真の自律性とは、チームメイトが自分で作業を見つけることだ。チームメイトが現在のタスクを完了したら、タスクボードで未確保の作業をスキャンし、タスクを確保し、作業を開始すべきだ -- リーダーからの指示なしに。\n\nしかし自律エージェントには微妙な問題がある: コンテキスト圧縮後に、エージェントが自分が誰かを忘れる可能性がある。メッセージが要約されると、元のシステムプロンプトのアイデンティティ(「あなたはalice、役割はcoder」)が薄れる。アイデンティティの再注入は、圧縮されたコンテキストの先頭にアイデンティティブロックを挿入することでこれを解決する。\n\n教育上の簡略化: ここで使用するトークン推定は大まかなもの(文字数 / 4)だ。本番システムでは適切なトークナイザーライブラリを使用する。nagの閾値3ラウンド(s03から)は教育目的の可視化のために低く設定されている。本番のエージェントでは閾値は約10。\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\n | (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 \"You are 'alice', role: coder, team: my-team\"\n```\n\n## 仕組み\n\n1. チームメイトのループにはWORKとIDLEの2つのフェーズがある。WORKは標準的なagent loopを実行する。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 inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\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 polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. タスクボードスキャンがpendingかつ未割り当てかつブロックされていないタスクを探す。\n\n```python\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\n unclaimed = []\n for f in sorted(TASKS_DLine truncated
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,886 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"id": "s01",
|
||||
"filename": "s01_agent_loop.py",
|
||||
"title": "The Agent Loop",
|
||||
"subtitle": "Bash is All You Need",
|
||||
"loc": 83,
|
||||
"tools": [
|
||||
"bash"
|
||||
],
|
||||
"newTools": [
|
||||
"bash"
|
||||
],
|
||||
"coreAddition": "Single-tool agent loop",
|
||||
"keyInsight": "The entire agent is a while loop + one tool",
|
||||
"classes": [],
|
||||
"functions": [
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 52
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 66
|
||||
}
|
||||
],
|
||||
"layer": "tools",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns01_agent_loop.py - The Agent Loop\n\nThe entire secret of coding agents in one pattern:\n\n while stop_reason == \"tool_use\":\n response = LLM(messages, tools)\n execute tools\n append results\n\n +----------+ +-------+ +---------+\n | User | ---> | LLM | ---> | Tool |\n | prompt | | | | execute |\n +----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThat's it. The ENTIRE agent is a while loop that feeds tool\nresults back to the model until the model decides to stop.\n\"\"\"\n\nimport os\nimport subprocess\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain.\"\n\nTOOLS = [{\n \"name\": \"bash\",\n \"description\": \"Run a shell command.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"],\n },\n}]\n\n\ndef run_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=os.getcwd(),\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\n# -- The core pattern: a while loop that calls tools until the model stops --\ndef agent_loop(messages: list):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n # Append assistant turn\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n # If the model didn't call a tool, we're done\n if response.stop_reason != \"tool_use\":\n return\n # Execute each tool call, collect results\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n print(f\"\\033[33m$ {block.input['command']}\\033[0m\")\n output = run_bash(block.input[\"command\"])\n print(output[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n history = []\n while True:\n try:\n query = input(\"\\033[36ms01 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n response_content = history[-1][\"content\"]\n if isinstance(response_content, list):\n for block in response_content:\n if hasattr(block, \"text\"):\n print(block.text)\n print()\n"
|
||||
},
|
||||
{
|
||||
"id": "s02",
|
||||
"filename": "s02_tool_use.py",
|
||||
"title": "Tools",
|
||||
"subtitle": "The Loop Didn't Change",
|
||||
"loc": 115,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file"
|
||||
],
|
||||
"newTools": [
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file"
|
||||
],
|
||||
"coreAddition": "Tool dispatch map",
|
||||
"keyInsight": "Adding tools means adding handlers, the loop stays the same",
|
||||
"classes": [],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 40
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 47
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 60
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 71
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 81
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 113
|
||||
}
|
||||
],
|
||||
"layer": "tools",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns02_tool_use.py - Tools\n\nThe agent loop from s01 didn't change. We just added tools to the array\nand a dispatch map to route calls.\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\nKey insight: \"The loop didn't change at all. I just added tools.\"\n\"\"\"\n\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain.\"\n\n\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\n\ndef run_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int = None) -> str:\n try:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)[:50000]\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n fp = safe_path(path)\n content = fp.read_text()\n if old_text not in content:\n return f\"Error: Text not found in {path}\"\n fp.write_text(content.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# -- The dispatch map: {tool_name: handler} --\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\"], kw[\"new_text\"]),\n}\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n]\n\n\ndef agent_loop(messages: list):\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 if response.stop_reason != \"tool_use\":\n return\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n Line truncated
|
||||
},
|
||||
{
|
||||
"id": "s03",
|
||||
"filename": "s03_todo_write.py",
|
||||
"title": "TodoWrite",
|
||||
"subtitle": "Plan Before You Act",
|
||||
"loc": 173,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"todo"
|
||||
],
|
||||
"newTools": [
|
||||
"todo"
|
||||
],
|
||||
"coreAddition": "TodoManager + nag reminder",
|
||||
"keyInsight": "Visible plans improve task completion and accountability",
|
||||
"classes": [
|
||||
{
|
||||
"name": "TodoManager",
|
||||
"startLine": 51,
|
||||
"endLine": 87
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 92
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 98
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 110
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 119
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 128
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 163
|
||||
}
|
||||
],
|
||||
"layer": "planning",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns03_todo_write.py - TodoWrite\n\nThe model tracks its own progress via a TodoManager. A nag reminder\nforces it to keep updating when it forgets.\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>\n\nKey insight: \"The agent can track its own progress -- and I can see it.\"\n\"\"\"\n\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n\n\n# -- TodoManager: structured state the LLM writes to --\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n if len(items) > 20:\n raise ValueError(\"Max 20 todos allowed\")\n validated = []\n in_progress_count = 0\n for i, item in enumerate(items):\n text = str(item.get(\"text\", \"\")).strip()\n status = str(item.get(\"status\", \"pending\")).lower()\n item_id = str(item.get(\"id\", str(i + 1)))\n if not text:\n raise ValueError(f\"Item {item_id}: text required\")\n if status not in (\"pending\", \"in_progress\", \"completed\"):\n raise ValueError(f\"Item {item_id}: invalid status '{status}'\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item_id, \"text\": text, \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress at a time\")\n self.items = validated\n return self.render()\n\n def render(self) -> str:\n if not self.items:\n return \"No todos.\"\n lines = []\n for item in self.items:\n marker = {\"pending\": \"[ ]\", \"in_progress\": \"[>]\", \"completed\": \"[x]\"}[item[\"status\"]]\n lines.append(f\"{marker} #{item['id']}: {item['text']}\")\n done = sum(1 for t in self.items if t[\"status\"] == \"completed\")\n lines.append(f\"\\n({done}/{len(self.items)} completed)\")\n return \"\\n\".join(lines)\n\n\nTODO = TodoManager()\n\n\n# -- Tool implementations --\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_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more)\"]\n return \"\\n\".join(lines)[:50000]\n except Exception as e:\n return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes\"\n except Exception as e:\n return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n fp = safe_path(path)\n content = fp.read_text()\n if old_text not in content:\n return f\"Error: Text not found in {path}\"\n fp.write_text(content.replace(old_text, new_text, 1))\n returLine truncated
|
||||
},
|
||||
{
|
||||
"id": "s04",
|
||||
"filename": "s04_subagent.py",
|
||||
"title": "Subagents",
|
||||
"subtitle": "Fresh Context via Task Tool",
|
||||
"loc": 146,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"task"
|
||||
],
|
||||
"newTools": [
|
||||
"task"
|
||||
],
|
||||
"coreAddition": "Subagent spawn with isolated messages[]",
|
||||
"keyInsight": "Process isolation = context isolation",
|
||||
"classes": [],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 46
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 52
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 64
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 73
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 82
|
||||
},
|
||||
{
|
||||
"name": "run_subagent",
|
||||
"signature": "def run_subagent(prompt: str)",
|
||||
"startLine": 115
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 143
|
||||
}
|
||||
],
|
||||
"layer": "planning",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns04_subagent.py - Subagents\n\nSpawn a child agent with fresh messages=[]. The child works in its own\ncontext, sharing the filesystem, then returns only a summary to the parent.\n\n Parent agent Subagent\n +------------------+ +------------------+\n | messages=[...] | | messages=[] | <-- fresh\n | | dispatch | |\n | tool: task | ---------->| while tool_use: |\n | prompt=\"...\" | | call tools |\n | description=\"\" | | append results |\n | | summary | |\n | result = \"...\" | <--------- | return last text |\n +------------------+ +------------------+\n |\n Parent context stays clean.\n Subagent context is discarded.\n\nKey insight: \"Process isolation gives context isolation for free.\"\n\"\"\"\n\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks.\"\nSUBAGENT_SYSTEM = f\"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings.\"\n\n\n# -- Tool implementations shared by parent and child --\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_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more)\"]\n return \"\\n\".join(lines)[:50000]\n except Exception as e:\n return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes\"\n except Exception as e:\n return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n fp = safe_path(path)\n content = fp.read_text()\n if old_text not in content:\n return f\"Error: Text not found in {path}\"\n fp.write_text(content.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\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\"], kw[\"new_text\"]),\n}\n\n# Child gets all base tools except task (no recursive spawning)\nCHILD_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n]\n\n\n# -- Subagent: fresh context, filtered tools, summary-only return --\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}] Line truncated
|
||||
},
|
||||
{
|
||||
"id": "s05",
|
||||
"filename": "s05_skill_loading.py",
|
||||
"title": "Skills",
|
||||
"subtitle": "SKILL.md + tool_result Injection",
|
||||
"loc": 177,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"load_skill"
|
||||
],
|
||||
"newTools": [
|
||||
"load_skill"
|
||||
],
|
||||
"coreAddition": "SkillLoader + two-layer injection",
|
||||
"keyInsight": "Skills inject via tool_result, not system prompt",
|
||||
"classes": [
|
||||
{
|
||||
"name": "SkillLoader",
|
||||
"startLine": 51,
|
||||
"endLine": 99
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 111
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 117
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 129
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 138
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 147
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 181
|
||||
}
|
||||
],
|
||||
"layer": "planning",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns05_skill_loading.py - Skills\n\nTwo-layer skill injection that avoids bloating the system prompt:\n\n Layer 1 (cheap): skill names in system prompt (~100 tokens/skill)\n Layer 2 (on demand): full skill body in tool_result\n\n System prompt:\n +--------------------------------------+\n | You are a coding agent. |\n | Skills available: |\n | - git: Git workflow helpers | <-- Layer 1: metadata only\n | - test: Testing best practices |\n +--------------------------------------+\n\n When model calls load_skill(\"git\"):\n +--------------------------------------+\n | tool_result: |\n | <skill> |\n | Full git workflow instructions... | <-- Layer 2: full body\n | Step 1: ... |\n | Step 2: ... |\n | </skill> |\n +--------------------------------------+\n\nKey insight: \"Don't put everything in the system prompt. Load on demand.\"\n\"\"\"\n\nimport os\nimport re\nimport subprocess\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nSKILLS_DIR = WORKDIR / \".skills\"\n\n\n# -- SkillLoader: parse .skills/*.md files with YAML frontmatter --\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills_dir = skills_dir\n self.skills = {}\n self._load_all()\n\n def _load_all(self):\n if not self.skills_dir.exists():\n return\n for f in sorted(self.skills_dir.glob(\"*.md\")):\n name = f.stem\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[name] = {\"meta\": meta, \"body\": body, \"path\": str(f)}\n\n def _parse_frontmatter(self, text: str) -> tuple:\n \"\"\"Parse YAML frontmatter between --- delimiters.\"\"\"\n match = re.match(r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL)\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n\n def get_descriptions(self) -> str:\n \"\"\"Layer 1: short descriptions for the system prompt.\"\"\"\n if not self.skills:\n return \"(no skills available)\"\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n tags = skill[\"meta\"].get(\"tags\", \"\")\n line = f\" - {name}: {desc}\"\n if tags:\n line += f\" [{tags}]\"\n lines.append(line)\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n \"\"\"Layer 2: full skill body returned in tool_result.\"\"\"\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n\n\nSKILL_LOADER = SkillLoader(SKILLS_DIR)\n\n# Layer 1: skill metadata injected into system prompt\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse load_skill to access specialized knowledge before tackling unfamiliar topics.\n\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\n\n# -- Tool implementations --\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_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more)\"]\n return \"\\n\".join(lines)[:50000]\n except Exception as e:\n return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -Line truncated
|
||||
},
|
||||
{
|
||||
"id": "s06",
|
||||
"filename": "s06_context_compact.py",
|
||||
"title": "Compact",
|
||||
"subtitle": "Strategic Forgetting",
|
||||
"loc": 200,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"compact"
|
||||
],
|
||||
"newTools": [
|
||||
"compact"
|
||||
],
|
||||
"coreAddition": "micro-compact + auto-compact + archival",
|
||||
"keyInsight": "Forgetting old context enables infinite-length sessions",
|
||||
"classes": [],
|
||||
"functions": [
|
||||
{
|
||||
"name": "estimate_tokens",
|
||||
"signature": "def estimate_tokens(messages: list)",
|
||||
"startLine": 61
|
||||
},
|
||||
{
|
||||
"name": "micro_compact",
|
||||
"signature": "def micro_compact(messages: list)",
|
||||
"startLine": 67
|
||||
},
|
||||
{
|
||||
"name": "auto_compact",
|
||||
"signature": "def auto_compact(messages: list)",
|
||||
"startLine": 97
|
||||
},
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 124
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 130
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 142
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 151
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 160
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 194
|
||||
}
|
||||
],
|
||||
"layer": "memory",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns06_context_compact.py - Compact\n\nThree-layer compression pipeline so the agent can work forever:\n\n Every turn:\n +------------------+\n | Tool call result |\n +------------------+\n |\n v\n [Layer 1: micro_compact] (silent, every turn)\n Replace tool_result content older than last 3\n with \"[Previous: used {tool_name}]\"\n |\n v\n [Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\n continue [Layer 2: auto_compact]\n Save full transcript to .transcripts/\n Ask LLM to summarize conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact -> immediate summarization.\n Same as auto, triggered manually.\n\nKey insight: \"The agent can forget strategically and keep working forever.\"\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport time\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n\nTHRESHOLD = 50000\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nKEEP_RECENT = 3\n\n\ndef estimate_tokens(messages: list) -> int:\n \"\"\"Rough token count: ~4 chars per token.\"\"\"\n return len(str(messages)) // 4\n\n\n# -- Layer 1: micro_compact - replace old tool results with placeholders --\ndef micro_compact(messages: list) -> list:\n # Collect (msg_index, part_index, tool_result_dict) for all tool_result entries\n tool_results = []\n for msg_idx, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for part_idx, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((msg_idx, part_idx, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n # Find tool_name for each result by matching tool_use_id in prior assistant messages\n tool_name_map = {}\n for msg in messages:\n if msg[\"role\"] == \"assistant\":\n content = msg.get(\"content\", [])\n if isinstance(content, list):\n for block in content:\n if hasattr(block, \"type\") and block.type == \"tool_use\":\n tool_name_map[block.id] = block.name\n # Clear old results (keep last KEEP_RECENT)\n to_clear = tool_results[:-KEEP_RECENT]\n for _, _, result in to_clear:\n if isinstance(result.get(\"content\"), str) and len(result[\"content\"]) > 100:\n tool_id = result.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n result[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n\n\n# -- Layer 2: auto_compact - save transcript, summarize, replace messages --\ndef auto_compact(messages: list) -> list:\n # Save full transcript to disk\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\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 print(f\"[transcript saved: {transcript_path}]\")\n # Ask LLM to summarize\n conversation_text = json.dumps(messages, default=str)[:80000]\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity. Include: \"\n \"1) What was accomplished, 2) Current state, 3) Key decisions made. \"\n \"Be concise but preserve critical details.\\n\\n\" + conversation_text}],\n max_tokens=2000,\n )\n summary = response.content[0].text\n # Replace all messages with compressed summary\n return [\n {\"role\": \"user\", \"content\": f\"[Conversation compressed. Transcript: {transcript_path}]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. I have the context from the summary. Continuing.\"},\n ]\n\n\n# -- Tool implementations --\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_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n iLine truncated
|
||||
},
|
||||
{
|
||||
"id": "s07",
|
||||
"filename": "s07_task_system.py",
|
||||
"title": "Tasks",
|
||||
"subtitle": "Persistent CRUD with Dependencies",
|
||||
"loc": 202,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"task_create",
|
||||
"task_update",
|
||||
"task_list",
|
||||
"task_get"
|
||||
],
|
||||
"newTools": [
|
||||
"task_create",
|
||||
"task_update",
|
||||
"task_list",
|
||||
"task_get"
|
||||
],
|
||||
"coreAddition": "TaskManager with file-based state + dependency graph",
|
||||
"keyInsight": "File-based state survives context compression",
|
||||
"classes": [
|
||||
{
|
||||
"name": "TaskManager",
|
||||
"startLine": 46,
|
||||
"endLine": 125
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 130
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 136
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 148
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 157
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 166
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 209
|
||||
}
|
||||
],
|
||||
"layer": "planning",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns07_task_system.py - Tasks\n\nTasks persist as JSON files in .tasks/ so they survive context compression.\nEach task has a dependency graph (blockedBy/blocks).\n\n .tasks/\n task_1.json {\"id\":1, \"subject\":\"...\", \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\", ...}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"blocks\":[], ...}\n\n Dependency resolution:\n +----------+ +----------+ +----------+\n | task 1 | --> | task 2 | --> | task 3 |\n | complete | | blocked | | blocked |\n +----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from task 2's blockedBy\n\nKey insight: \"State that survives compression -- because it's outside the conversation.\"\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nTASKS_DIR = WORKDIR / \".tasks\"\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use task tools to plan and track work.\"\n\n\n# -- TaskManager: CRUD with dependency graph, persisted as JSON files --\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 _max_id(self) -> int:\n ids = [int(f.stem.split(\"_\")[1]) for f in self.dir.glob(\"task_*.json\")]\n return max(ids) if ids else 0\n\n def _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n if not path.exists():\n raise ValueError(f\"Task {task_id} not found\")\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\n\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id, \"subject\": subject, \"description\": description,\n \"status\": \"pending\", \"blockedBy\": [], \"blocks\": [], \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n\n def get(self, task_id: int) -> str:\n return json.dumps(self._load(task_id), indent=2)\n\n def update(self, task_id: int, status: str = None,\n add_blocked_by: list = None, add_blocks: list = None) -> str:\n task = self._load(task_id)\n if status:\n if status not in (\"pending\", \"in_progress\", \"completed\"):\n raise ValueError(f\"Invalid status: {status}\")\n task[\"status\"] = status\n # When a task is completed, remove it from all other tasks' blockedBy\n if status == \"completed\":\n self._clear_dependency(task_id)\n if add_blocked_by:\n task[\"blockedBy\"] = list(set(task[\"blockedBy\"] + add_blocked_by))\n if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n # Bidirectional: also update the blocked tasks' blockedBy lists\n for blocked_id in add_blocks:\n try:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\n except ValueError:\n pass\n self._save(task)\n return json.dumps(task, indent=2)\n\n def _clear_dependency(self, completed_id: int):\n \"\"\"Remove completed_id from all other tasks' blockedBy lists.\"\"\"\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 def list_all(self) -> str:\n tasks = []\n for f in sorted(self.dir.glob(\"task_*.json\")):\n tasks.append(json.loads(f.read_text()))\n if not tasks:\n return \"No tasks.\"\n lines = []\n for t in tasks:\n marker = {\"pending\": \"[ ]\", \"in_progress\": \"[>]\", \"completed\": \"[x]\"}.get(t[\"status\"], \"[?]\")\n blocked = f\" (blocked by: {t['blockedBy']})\" if t.get(\"blockedBy\") else \"\"\n lines.append(f\"{marker} #{t['id']}: {t['subject']}{blocked}\")\n return \"\\n\".join(lines)\n\n\nTASKS = TaskManager(TASKS_DIR)\n\n\n# -- Base tool implementations --\ndef safe_path(p: str)Line truncated
|
||||
},
|
||||
{
|
||||
"id": "s08",
|
||||
"filename": "s08_background_tasks.py",
|
||||
"title": "Background Tasks",
|
||||
"subtitle": "Fire and Forget",
|
||||
"loc": 193,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"background_run",
|
||||
"check_background"
|
||||
],
|
||||
"newTools": [
|
||||
"background_run",
|
||||
"check_background"
|
||||
],
|
||||
"coreAddition": "BackgroundManager + notification queue",
|
||||
"keyInsight": "Non-blocking daemon threads + notification queue",
|
||||
"classes": [
|
||||
{
|
||||
"name": "BackgroundManager",
|
||||
"startLine": 49,
|
||||
"endLine": 109
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "safe_path",
|
||||
"signature": "def safe_path(p: str)",
|
||||
"startLine": 114
|
||||
},
|
||||
{
|
||||
"name": "run_bash",
|
||||
"signature": "def run_bash(command: str)",
|
||||
"startLine": 120
|
||||
},
|
||||
{
|
||||
"name": "run_read",
|
||||
"signature": "def run_read(path: str, limit: int = None)",
|
||||
"startLine": 132
|
||||
},
|
||||
{
|
||||
"name": "run_write",
|
||||
"signature": "def run_write(path: str, content: str)",
|
||||
"startLine": 141
|
||||
},
|
||||
{
|
||||
"name": "run_edit",
|
||||
"signature": "def run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 150
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 187
|
||||
}
|
||||
],
|
||||
"layer": "concurrency",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns08_background_tasks.py - Background Tasks\n\nRun commands in background threads. A notification queue is drained\nbefore each LLM call to deliver results.\n\n Main thread Background thread\n +-----------------+ +-----------------+\n | agent loop | | task executes |\n | ... | | ... |\n | [LLM call] <---+------- | enqueue(result) |\n | ^drain queue | +-----------------+\n +-----------------+\n\n Timeline:\n Agent ----[spawn A]----[spawn B]----[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- notification queue --> [results injected]\n\nKey insight: \"Fire and forget -- the agent doesn't block while the command runs.\"\n\"\"\"\n\nimport os\nimport subprocess\nimport threading\nimport uuid\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\n\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use background_run for long-running commands.\"\n\n\n# -- BackgroundManager: threaded execution + notification queue --\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {} # task_id -> {status, result, command}\n self._notification_queue = [] # completed task results\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n \"\"\"Start a background thread, return task_id immediately.\"\"\"\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"result\": None, \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True\n )\n thread.start()\n return f\"Background task {task_id} started: {command[:80]}\"\n\n def _execute(self, task_id: str, command: str):\n \"\"\"Thread target: run subprocess, capture output, push to queue.\"\"\"\n try:\n r = subprocess.run(\n command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300\n )\n output = (r.stdout + r.stderr).strip()[:50000]\n status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n except Exception as e:\n output = f\"Error: {e}\"\n status = \"error\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output or \"(no output)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"command\": command[:80],\n \"result\": (output or \"(no output)\")[:500],\n })\n\n def check(self, task_id: str = None) -> str:\n \"\"\"Check status of one task or list all.\"\"\"\n if task_id:\n t = self.tasks.get(task_id)\n if not t:\n return f\"Error: Unknown task {task_id}\"\n return f\"[{t['status']}] {t['command'][:60]}\\n{t.get('result') or '(running)'}\"\n lines = []\n for tid, t in self.tasks.items():\n lines.append(f\"{tid}: [{t['status']}] {t['command'][:60]}\")\n return \"\\n\".join(lines) if lines else \"No background tasks.\"\n\n def drain_notifications(self) -> list:\n \"\"\"Return and clear all pending completion notifications.\"\"\"\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n\n\nBG = BackgroundManager()\n\n\n# -- Tool implementations --\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_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit anLine truncated
|
||||
},
|
||||
{
|
||||
"id": "s09",
|
||||
"filename": "s09_agent_teams.py",
|
||||
"title": "Agent Teams",
|
||||
"subtitle": "Teammates + Mailboxes",
|
||||
"loc": 343,
|
||||
"tools": [
|
||||
"alice",
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"send_message",
|
||||
"read_inbox",
|
||||
"spawn_teammate",
|
||||
"list_teammates",
|
||||
"broadcast"
|
||||
],
|
||||
"newTools": [
|
||||
"alice",
|
||||
"send_message",
|
||||
"read_inbox",
|
||||
"spawn_teammate",
|
||||
"list_teammates",
|
||||
"broadcast"
|
||||
],
|
||||
"coreAddition": "TeammateManager + file-based mailbox",
|
||||
"keyInsight": "Persistent teammates with async mailbox inboxes",
|
||||
"classes": [
|
||||
{
|
||||
"name": "MessageBus",
|
||||
"startLine": 77,
|
||||
"endLine": 118
|
||||
},
|
||||
{
|
||||
"name": "TeammateManager",
|
||||
"startLine": 123,
|
||||
"endLine": 249
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "_safe_path",
|
||||
"signature": "def _safe_path(p: str)",
|
||||
"startLine": 254
|
||||
},
|
||||
{
|
||||
"name": "_run_bash",
|
||||
"signature": "def _run_bash(command: str)",
|
||||
"startLine": 261
|
||||
},
|
||||
{
|
||||
"name": "_run_read",
|
||||
"signature": "def _run_read(path: str, limit: int = None)",
|
||||
"startLine": 276
|
||||
},
|
||||
{
|
||||
"name": "_run_write",
|
||||
"signature": "def _run_write(path: str, content: str)",
|
||||
"startLine": 286
|
||||
},
|
||||
{
|
||||
"name": "_run_edit",
|
||||
"signature": "def _run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 296
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 344
|
||||
}
|
||||
],
|
||||
"layer": "collaboration",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns09_agent_teams.py - Agent Teams\n\nPersistent named agents with file-based JSONL inboxes. Each teammate runs\nits own agent loop in a separate thread. Communication via append-only inboxes.\n\n Subagent (s04): spawn -> execute -> return summary -> destroyed\n Teammate (s09): spawn -> work -> idle -> work -> ... -> shutdown\n\n .team/config.json .team/inbox/\n +----------------------------+ +------------------+\n | {\"team_name\": \"default\", | | alice.jsonl |\n | \"members\": [ | | bob.jsonl |\n | {\"name\":\"alice\", | | lead.jsonl |\n | \"role\":\"coder\", | +------------------+\n | \"status\":\"idle\"} |\n | ]} | send_message(\"alice\", \"fix bug\"):\n +----------------------------+ open(\"alice.jsonl\", \"a\").write(msg)\n\n read_inbox(\"alice\"):\n spawn_teammate(\"alice\",\"coder\",...) msgs = [json.loads(l) for l in ...]\n | open(\"alice.jsonl\", \"w\").close()\n v return msgs # drain\n Thread: alice Thread: bob\n +------------------+ +------------------+\n | agent_loop | | agent_loop |\n | status: working | | status: idle |\n | ... runs tools | | ... waits ... |\n | status -> idle | | |\n +------------------+ +------------------+\n\n 5 message types (all declared, not all handled here):\n +-------------------------+-----------------------------------+\n | message | Normal text message |\n | broadcast | Sent to all teammates |\n | shutdown_request | Request graceful shutdown (s10) |\n | shutdown_response | Approve/reject shutdown (s10) |\n | plan_approval_response | Approve/reject plan (s10) |\n +-------------------------+-----------------------------------+\n\nKey insight: \"Teammates that can talk to each other.\"\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport threading\nimport time\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nTEAM_DIR = WORKDIR / \".team\"\nINBOX_DIR = TEAM_DIR / \"inbox\"\n\nSYSTEM = f\"You are a team lead at {WORKDIR}. Spawn teammates and communicate via inboxes.\"\n\nVALID_MSG_TYPES = {\n \"message\",\n \"broadcast\",\n \"shutdown_request\",\n \"shutdown_response\",\n \"plan_approval_response\",\n}\n\n\n# -- MessageBus: JSONL inbox per teammate --\nclass MessageBus:\n def __init__(self, inbox_dir: Path):\n self.dir = inbox_dir\n self.dir.mkdir(parents=True, exist_ok=True)\n\n def send(self, sender: str, to: str, content: str,\n msg_type: str = \"message\", extra: dict = None) -> str:\n if msg_type not in VALID_MSG_TYPES:\n return f\"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}\"\n msg = {\n \"type\": msg_type,\n \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time(),\n }\n if extra:\n msg.update(extra)\n inbox_path = self.dir / f\"{to}.jsonl\"\n with open(inbox_path, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name: str) -> list:\n inbox_path = self.dir / f\"{name}.jsonl\"\n if not inbox_path.exists():\n return []\n messages = []\n for line in inbox_path.read_text().strip().splitlines():\n if line:\n messages.append(json.loads(line))\n inbox_path.write_text(\"\")\n return messages\n\n def broadcast(self, sender: str, content: str, teammates: list) -> str:\n count = 0\n for name in teammates:\n if name != sender:\n self.send(sender, name, content, \"broadcast\")\n count += 1\n return f\"Broadcast to {count} teammates\"\n\n\nBUS = MessageBus(INBOX_DIR)\n\n\n# -- TeammateManager: persistent named agents with config.json --\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 def _load_config(self) -> dict:\n if self.config_path.exists():\n return json.loads(self.config_path.read_text())\n Line truncated
|
||||
},
|
||||
{
|
||||
"id": "s10",
|
||||
"filename": "s10_team_protocols.py",
|
||||
"title": "Team Protocols",
|
||||
"subtitle": "Shutdown + Plan Approval",
|
||||
"loc": 414,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"send_message",
|
||||
"read_inbox",
|
||||
"shutdown_response",
|
||||
"plan_approval",
|
||||
"spawn_teammate",
|
||||
"list_teammates",
|
||||
"broadcast",
|
||||
"shutdown_request"
|
||||
],
|
||||
"newTools": [
|
||||
"shutdown_response",
|
||||
"plan_approval",
|
||||
"shutdown_request"
|
||||
],
|
||||
"coreAddition": "request_id correlation for two protocols",
|
||||
"keyInsight": "Same request-response pattern, two applications",
|
||||
"classes": [
|
||||
{
|
||||
"name": "MessageBus",
|
||||
"startLine": 87,
|
||||
"endLine": 128
|
||||
},
|
||||
{
|
||||
"name": "TeammateManager",
|
||||
"startLine": 133,
|
||||
"endLine": 290
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "_safe_path",
|
||||
"signature": "def _safe_path(p: str)",
|
||||
"startLine": 295
|
||||
},
|
||||
{
|
||||
"name": "_run_bash",
|
||||
"signature": "def _run_bash(command: str)",
|
||||
"startLine": 302
|
||||
},
|
||||
{
|
||||
"name": "_run_read",
|
||||
"signature": "def _run_read(path: str, limit: int = None)",
|
||||
"startLine": 317
|
||||
},
|
||||
{
|
||||
"name": "_run_write",
|
||||
"signature": "def _run_write(path: str, content: str)",
|
||||
"startLine": 327
|
||||
},
|
||||
{
|
||||
"name": "_run_edit",
|
||||
"signature": "def _run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 337
|
||||
},
|
||||
{
|
||||
"name": "handle_shutdown_request",
|
||||
"signature": "def handle_shutdown_request(teammate: str)",
|
||||
"startLine": 350
|
||||
},
|
||||
{
|
||||
"name": "handle_plan_review",
|
||||
"signature": "def handle_plan_review(request_id: str, approve: bool, feedback: str = \"\")",
|
||||
"startLine": 361
|
||||
},
|
||||
{
|
||||
"name": "_check_shutdown_status",
|
||||
"signature": "def _check_shutdown_status(request_id: str)",
|
||||
"startLine": 375
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 425
|
||||
}
|
||||
],
|
||||
"layer": "collaboration",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns10_team_protocols.py - Team Protocols\n\nShutdown protocol and plan approval protocol, both using the same\nrequest_id correlation pattern. Builds on s09's team messaging.\n\n Shutdown FSM: pending -> approved | rejected\n\n Lead Teammate\n +---------------------+ +---------------------+\n | shutdown_request | | |\n | { | -------> | receives request |\n | request_id: abc | | decides: approve? |\n | } | | |\n +---------------------+ +---------------------+\n |\n +---------------------+ +-------v-------------+\n | shutdown_response | <------- | shutdown_response |\n | { | | { |\n | request_id: abc | | request_id: abc |\n | approve: true | | approve: true |\n | } | | } |\n +---------------------+ +---------------------+\n |\n v\n status -> \"shutdown\", thread stops\n\n Plan approval FSM: pending -> approved | rejected\n\n Teammate Lead\n +---------------------+ +---------------------+\n | plan_approval | | |\n | submit: {plan:\"...\"}| -------> | reviews plan text |\n +---------------------+ | approve/reject? |\n +---------------------+\n |\n +---------------------+ +-------v-------------+\n | plan_approval_resp | <------- | plan_approval |\n | {approve: true} | | review: {req_id, |\n +---------------------+ | approve: true} |\n +---------------------+\n\n Trackers: {request_id: {\"target|from\": name, \"status\": \"pending|...\"}}\n\nKey insight: \"Same request_id correlation pattern, two domains.\"\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport threading\nimport time\nimport uuid\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nTEAM_DIR = WORKDIR / \".team\"\nINBOX_DIR = TEAM_DIR / \"inbox\"\n\nSYSTEM = f\"You are a team lead at {WORKDIR}. Manage teammates with shutdown and plan approval protocols.\"\n\nVALID_MSG_TYPES = {\n \"message\",\n \"broadcast\",\n \"shutdown_request\",\n \"shutdown_response\",\n \"plan_approval_response\",\n}\n\n# -- Request trackers: correlate by request_id --\nshutdown_requests = {}\nplan_requests = {}\n_tracker_lock = threading.Lock()\n\n\n# -- MessageBus: JSONL inbox per teammate --\nclass MessageBus:\n def __init__(self, inbox_dir: Path):\n self.dir = inbox_dir\n self.dir.mkdir(parents=True, exist_ok=True)\n\n def send(self, sender: str, to: str, content: str,\n msg_type: str = \"message\", extra: dict = None) -> str:\n if msg_type not in VALID_MSG_TYPES:\n return f\"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}\"\n msg = {\n \"type\": msg_type,\n \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time(),\n }\n if extra:\n msg.update(extra)\n inbox_path = self.dir / f\"{to}.jsonl\"\n with open(inbox_path, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name: str) -> list:\n inbox_path = self.dir / f\"{name}.jsonl\"\n if not inbox_path.exists():\n return []\n messages = []\n for line in inbox_path.read_text().strip().splitlines():\n if line:\n messages.append(json.loads(line))\n inbox_path.write_text(\"\")\n return messages\n\n def broadcast(self, sender: str, content: str, teammates: list) -> str:\n count = 0\n for name in teammates:\n if name != sender:\n self.send(sender, name, content, \"broadcast\")\n count += 1\n return f\"Broadcast to {count} teammates\"\n\n\nBUS = MessageBus(INBOX_DIR)\n\n\n# -- TeammateManager with shutdown + plan approval --\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 def _load_conLine truncated
|
||||
},
|
||||
{
|
||||
"id": "s11",
|
||||
"filename": "s11_autonomous_agents.py",
|
||||
"title": "Autonomous Agents",
|
||||
"subtitle": "Idle Cycle + Auto-Claim",
|
||||
"loc": 494,
|
||||
"tools": [
|
||||
"bash",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"send_message",
|
||||
"read_inbox",
|
||||
"shutdown_response",
|
||||
"plan_approval",
|
||||
"idle",
|
||||
"claim_task",
|
||||
"spawn_teammate",
|
||||
"list_teammates",
|
||||
"broadcast",
|
||||
"shutdown_request"
|
||||
],
|
||||
"newTools": [
|
||||
"idle",
|
||||
"claim_task"
|
||||
],
|
||||
"coreAddition": "Task board polling + timeout-based self-governance",
|
||||
"keyInsight": "Polling + timeout makes teammates self-organizing",
|
||||
"classes": [
|
||||
{
|
||||
"name": "MessageBus",
|
||||
"startLine": 80,
|
||||
"endLine": 121
|
||||
},
|
||||
{
|
||||
"name": "TeammateManager",
|
||||
"startLine": 159,
|
||||
"endLine": 368
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"name": "scan_unclaimed_tasks",
|
||||
"signature": "def scan_unclaimed_tasks()",
|
||||
"startLine": 126
|
||||
},
|
||||
{
|
||||
"name": "claim_task",
|
||||
"signature": "def claim_task(task_id: int, owner: str)",
|
||||
"startLine": 138
|
||||
},
|
||||
{
|
||||
"name": "make_identity_block",
|
||||
"signature": "def make_identity_block(name: str, role: str, team_name: str)",
|
||||
"startLine": 151
|
||||
},
|
||||
{
|
||||
"name": "_safe_path",
|
||||
"signature": "def _safe_path(p: str)",
|
||||
"startLine": 373
|
||||
},
|
||||
{
|
||||
"name": "_run_bash",
|
||||
"signature": "def _run_bash(command: str)",
|
||||
"startLine": 380
|
||||
},
|
||||
{
|
||||
"name": "_run_read",
|
||||
"signature": "def _run_read(path: str, limit: int = None)",
|
||||
"startLine": 395
|
||||
},
|
||||
{
|
||||
"name": "_run_write",
|
||||
"signature": "def _run_write(path: str, content: str)",
|
||||
"startLine": 405
|
||||
},
|
||||
{
|
||||
"name": "_run_edit",
|
||||
"signature": "def _run_edit(path: str, old_text: str, new_text: str)",
|
||||
"startLine": 415
|
||||
},
|
||||
{
|
||||
"name": "handle_shutdown_request",
|
||||
"signature": "def handle_shutdown_request(teammate: str)",
|
||||
"startLine": 428
|
||||
},
|
||||
{
|
||||
"name": "handle_plan_review",
|
||||
"signature": "def handle_plan_review(request_id: str, approve: bool, feedback: str = \"\")",
|
||||
"startLine": 439
|
||||
},
|
||||
{
|
||||
"name": "_check_shutdown_status",
|
||||
"signature": "def _check_shutdown_status(request_id: str)",
|
||||
"startLine": 453
|
||||
},
|
||||
{
|
||||
"name": "agent_loop",
|
||||
"signature": "def agent_loop(messages: list)",
|
||||
"startLine": 509
|
||||
}
|
||||
],
|
||||
"layer": "collaboration",
|
||||
"source": "#!/usr/bin/env python3\n\"\"\"\ns11_autonomous_agents.py - Autonomous Agents\n\nIdle cycle with task board polling, auto-claiming unclaimed tasks, and\nidentity re-injection after context compression. Builds on s10's protocols.\n\n Teammate lifecycle:\n +-------+\n | spawn |\n +---+---+\n |\n v\n +-------+ tool_use +-------+\n | WORK | <----------- | LLM |\n +---+---+ +-------+\n |\n | stop_reason != tool_use\n v\n +--------+\n | IDLE | poll every 5s for up to 60s\n +---+----+\n |\n +---> check inbox -> message? -> resume WORK\n |\n +---> scan .tasks/ -> unclaimed? -> claim -> resume WORK\n |\n +---> timeout (60s) -> shutdown\n\n Identity re-injection after compression:\n messages = [identity_block, ...remaining...]\n \"You are 'coder', role: backend, team: my-team\"\n\nKey insight: \"The agent finds work itself.\"\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport threading\nimport time\nimport uuid\nfrom pathlib import Path\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nTEAM_DIR = WORKDIR / \".team\"\nINBOX_DIR = TEAM_DIR / \"inbox\"\nTASKS_DIR = WORKDIR / \".tasks\"\n\nPOLL_INTERVAL = 5\nIDLE_TIMEOUT = 60\n\nSYSTEM = f\"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves.\"\n\nVALID_MSG_TYPES = {\n \"message\",\n \"broadcast\",\n \"shutdown_request\",\n \"shutdown_response\",\n \"plan_approval_response\",\n}\n\n# -- Request trackers --\nshutdown_requests = {}\nplan_requests = {}\n_tracker_lock = threading.Lock()\n_claim_lock = threading.Lock()\n\n\n# -- MessageBus: JSONL inbox per teammate --\nclass MessageBus:\n def __init__(self, inbox_dir: Path):\n self.dir = inbox_dir\n self.dir.mkdir(parents=True, exist_ok=True)\n\n def send(self, sender: str, to: str, content: str,\n msg_type: str = \"message\", extra: dict = None) -> str:\n if msg_type not in VALID_MSG_TYPES:\n return f\"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}\"\n msg = {\n \"type\": msg_type,\n \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time(),\n }\n if extra:\n msg.update(extra)\n inbox_path = self.dir / f\"{to}.jsonl\"\n with open(inbox_path, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name: str) -> list:\n inbox_path = self.dir / f\"{name}.jsonl\"\n if not inbox_path.exists():\n return []\n messages = []\n for line in inbox_path.read_text().strip().splitlines():\n if line:\n messages.append(json.loads(line))\n inbox_path.write_text(\"\")\n return messages\n\n def broadcast(self, sender: str, content: str, teammates: list) -> str:\n count = 0\n for name in teammates:\n if name != sender:\n self.send(sender, name, content, \"broadcast\")\n count += 1\n return f\"Broadcast to {count} teammates\"\n\n\nBUS = MessageBus(INBOX_DIR)\n\n\n# -- Task board scanning --\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\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\ndef claim_task(task_id: int, owner: str) -> str:\n with _claim_lock:\n path = TASKS_DIR / f\"task_{task_id}.json\"\n if not path.exists():\n return f\"Error: Task {task_id} not found\"\n task = json.loads(path.read_text())\n task[\"owner\"] = owner\n task[\"status\"] = \"in_progress\"\n path.write_text(json.dumps(task, indent=2))\n return f\"Claimed task #{task_id} for {owner}\"\n\n\n# -- Identity re-injection after compression --\ndef make_identity_block(name: str, role: str, team_name: str) -> dict:\n return {\n \"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, team: {team_name}. Continue your work.</identity>\",\n }\n\n\n# -- Autonomous TeammateManager --\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 Line truncated
|
||||
}
|
||||
],
|
||||
"diffs": [
|
||||
{
|
||||
"from": "s01",
|
||||
"to": "s02",
|
||||
"newClasses": [],
|
||||
"newFunctions": [
|
||||
"safe_path",
|
||||
"run_read",
|
||||
"run_write",
|
||||
"run_edit"
|
||||
],
|
||||
"newTools": [
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file"
|
||||
],
|
||||
"locDelta": 32
|
||||
},
|
||||
{
|
||||
"from": "s02",
|
||||
"to": "s03",
|
||||
"newClasses": [
|
||||
"TodoManager"
|
||||
],
|
||||
"newFunctions": [],
|
||||
"newTools": [
|
||||
"todo"
|
||||
],
|
||||
"locDelta": 58
|
||||
},
|
||||
{
|
||||
"from": "s03",
|
||||
"to": "s04",
|
||||
"newClasses": [],
|
||||
"newFunctions": [
|
||||
"run_subagent"
|
||||
],
|
||||
"newTools": [
|
||||
"task"
|
||||
],
|
||||
"locDelta": -27
|
||||
},
|
||||
{
|
||||
"from": "s04",
|
||||
"to": "s05",
|
||||
"newClasses": [
|
||||
"SkillLoader"
|
||||
],
|
||||
"newFunctions": [],
|
||||
"newTools": [
|
||||
"load_skill"
|
||||
],
|
||||
"locDelta": 31
|
||||
},
|
||||
{
|
||||
"from": "s05",
|
||||
"to": "s06",
|
||||
"newClasses": [],
|
||||
"newFunctions": [
|
||||
"estimate_tokens",
|
||||
"micro_compact",
|
||||
"auto_compact"
|
||||
],
|
||||
"newTools": [
|
||||
"compact"
|
||||
],
|
||||
"locDelta": 23
|
||||
},
|
||||
{
|
||||
"from": "s06",
|
||||
"to": "s07",
|
||||
"newClasses": [
|
||||
"TaskManager"
|
||||
],
|
||||
"newFunctions": [],
|
||||
"newTools": [
|
||||
"task_create",
|
||||
"task_update",
|
||||
"task_list",
|
||||
"task_get"
|
||||
],
|
||||
"locDelta": 2
|
||||
},
|
||||
{
|
||||
"from": "s07",
|
||||
"to": "s08",
|
||||
"newClasses": [
|
||||
"BackgroundManager"
|
||||
],
|
||||
"newFunctions": [],
|
||||
"newTools": [
|
||||
"background_run",
|
||||
"check_background"
|
||||
],
|
||||
"locDelta": -9
|
||||
},
|
||||
{
|
||||
"from": "s08",
|
||||
"to": "s09",
|
||||
"newClasses": [
|
||||
"MessageBus",
|
||||
"TeammateManager"
|
||||
],
|
||||
"newFunctions": [
|
||||
"_safe_path",
|
||||
"_run_bash",
|
||||
"_run_read",
|
||||
"_run_write",
|
||||
"_run_edit"
|
||||
],
|
||||
"newTools": [
|
||||
"alice",
|
||||
"send_message",
|
||||
"read_inbox",
|
||||
"spawn_teammate",
|
||||
"list_teammates",
|
||||
"broadcast"
|
||||
],
|
||||
"locDelta": 150
|
||||
},
|
||||
{
|
||||
"from": "s09",
|
||||
"to": "s10",
|
||||
"newClasses": [],
|
||||
"newFunctions": [
|
||||
"handle_shutdown_request",
|
||||
"handle_plan_review",
|
||||
"_check_shutdown_status"
|
||||
],
|
||||
"newTools": [
|
||||
"shutdown_response",
|
||||
"plan_approval",
|
||||
"shutdown_request"
|
||||
],
|
||||
"locDelta": 71
|
||||
},
|
||||
{
|
||||
"from": "s10",
|
||||
"to": "s11",
|
||||
"newClasses": [],
|
||||
"newFunctions": [
|
||||
"scan_unclaimed_tasks",
|
||||
"claim_task",
|
||||
"make_identity_block"
|
||||
],
|
||||
"newTools": [
|
||||
"idle",
|
||||
"claim_task"
|
||||
],
|
||||
"locDelta": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "s01",
|
||||
"title": "The Agent Loop",
|
||||
"description": "A minimal agent that uses only bash to accomplish tasks",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Create a file called hello.py 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"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "echo 'print(\"Hello, World!\")' > hello.py",
|
||||
"toolName": "bash",
|
||||
"annotation": "Tool call: the model generates a bash command"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "",
|
||||
"toolName": "bash",
|
||||
"annotation": "Bash returns empty output (success)"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Let me verify the file was created correctly.",
|
||||
"annotation": "The loop continues: model sees the result and decides next action"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "cat hello.py",
|
||||
"toolName": "bash",
|
||||
"annotation": "Another bash call to verify"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "print(\"Hello, World!\")",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": "s02",
|
||||
"title": "Tools",
|
||||
"description": "Agent with read, write, edit, and bash tools",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Add a function called greet(name) to hello.py",
|
||||
"annotation": "User requests a code modification"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Let me read the current file first.",
|
||||
"annotation": "Model chooses read_file instead of bash cat"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "hello.py",
|
||||
"toolName": "read_file",
|
||||
"annotation": "Dedicated read tool provides cleaner file access"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "print(\"Hello, World!\")",
|
||||
"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\")",
|
||||
"toolName": "write_file",
|
||||
"annotation": "write_file replaces the entire file content"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "File written successfully",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Write confirmed"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I've updated hello.py with a greet(name) function that takes a name parameter.",
|
||||
"annotation": "4 tools beat 1: each tool has a clear, focused purpose"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": "s03",
|
||||
"title": "TodoWrite",
|
||||
"description": "Agent creates a visible plan before executing",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Build a CLI calculator that supports add, subtract, multiply, divide",
|
||||
"annotation": "A multi-step task that benefits from planning"
|
||||
},
|
||||
{
|
||||
"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]",
|
||||
"toolName": "todo_write",
|
||||
"annotation": "TodoManager creates a visible plan BEFORE any code is written"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Todo list created with 4 tasks",
|
||||
"toolName": "todo_write",
|
||||
"annotation": "The plan is now visible and trackable"
|
||||
},
|
||||
{
|
||||
"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))",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Executing task #1 and #2: create file with operations"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "File written successfully",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Code written"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "python calc.py 10 + 5",
|
||||
"toolName": "bash",
|
||||
"annotation": "Executing task #4: testing"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "15.0",
|
||||
"toolName": "bash",
|
||||
"annotation": "Test passes"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Calculator built and tested. All 4 operations work correctly.",
|
||||
"annotation": "Visible plans improve task completion by making progress trackable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "s04",
|
||||
"title": "Subagents",
|
||||
"description": "Agent spawns a subagent for isolated subtask execution",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Refactor the calculator: split into modules and add tests",
|
||||
"annotation": "Complex task suitable for delegation"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll delegate the test writing to a subagent while I handle the refactoring.",
|
||||
"annotation": "Agent decides to split work using the Task tool"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "Write unit tests for calc.py covering all 4 operations and edge cases like division by zero",
|
||||
"toolName": "task",
|
||||
"annotation": "Task tool spawns a subagent with its own isolated context"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[Subagent spawned] Context: fresh messages[], same tools, scoped prompt",
|
||||
"annotation": "Subagent gets clean context -- no confusion from parent's conversation"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Created test_calc.py 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",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Parent continues with its own task: refactoring"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Files written successfully",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Modules split"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Refactored into calc/operations.py and calc/cli.py. Tests were written by a subagent and are all passing.",
|
||||
"annotation": "Context isolation prevents confusion between tasks"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "s05",
|
||||
"title": "Skills",
|
||||
"description": "Agent loads external knowledge from SKILL.md files",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Set up a new Next.js project with TypeScript",
|
||||
"annotation": "Task requires framework-specific knowledge"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[SkillLoader] Scanning for SKILL.md files... Found: nextjs.SKILL.md",
|
||||
"annotation": "SkillLoader finds a matching skill file before the loop runs"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[Skill injected] nextjs.SKILL.md -> tool_result message appended",
|
||||
"annotation": "Skill content is injected as a tool_result, not bloating the system prompt"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I have knowledge about Next.js setup. Let me create the project structure.",
|
||||
"annotation": "Model now has framework-specific knowledge from the skill file"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir",
|
||||
"toolName": "bash",
|
||||
"annotation": "Using skill-provided knowledge for correct flags"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Success! Created my-app at /workspace/my-app",
|
||||
"toolName": "bash",
|
||||
"annotation": "Project created with correct configuration"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Next.js project created with TypeScript, Tailwind CSS, ESLint, App Router, and src/ directory.",
|
||||
"annotation": "Skills inject via tool_result, not system prompt -- keeping context lean"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "s06",
|
||||
"title": "Compact",
|
||||
"description": "Agent compresses context when tokens exceed threshold",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Analyze all Python 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",
|
||||
"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)",
|
||||
"toolName": "bash",
|
||||
"annotation": "32 files found -- reading all of them will generate massive context"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll read and analyze each file systematically.",
|
||||
"annotation": "Agent begins processing files one by one"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "./src/main.py",
|
||||
"toolName": "read_file",
|
||||
"annotation": "Reading file 1 of 32..."
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "[... 450 lines of code ...]",
|
||||
"toolName": "read_file",
|
||||
"annotation": "Large file content added to messages[]"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[ContextManager] Token count: 148,231 / 150,000 threshold\nTriggering 3-layer compression:\n 1. Drop tool_result content > 1000 chars -> replaced with summary\n 2. Merge sequential assistant turns\n 3. Summarize oldest 50% of conversation\nResult: 148,231 -> 42,087 tokens",
|
||||
"annotation": "Compression fires automatically when approaching the limit"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Continuing analysis with compressed context. Files 1-18 have been summarized, proceeding with remaining files.",
|
||||
"annotation": "Forgetting old tool results enables infinite work -- the agent can process any number of files"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": "s07",
|
||||
"title": "Tasks",
|
||||
"description": "Agent uses persistent file-based tasks with dependencies",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Build a REST API with user auth, database, and deployment config",
|
||||
"annotation": "Multi-part task requiring dependency tracking"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "create_task(\"Set up database schema\", deps=[])\ncreate_task(\"Implement user model\", deps=[1])\ncreate_task(\"Add auth endpoints\", deps=[2])\ncreate_task(\"Write deployment config\", deps=[3])",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "TaskManager creates file-based tasks with explicit dependencies"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Created 4 tasks in .tasks/\n #1 Set up database schema [ready]\n #2 Implement user model [blocked by #1]\n #3 Add auth endpoints [blocked by #2]\n #4 Write deployment config [blocked by #3]",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "Tasks persisted to .tasks/ directory as JSON files"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "schema.sql\n---\nCREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE, password_hash TEXT);",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Working on task #1"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "File written successfully",
|
||||
"toolName": "write_file",
|
||||
"annotation": "Schema created"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "complete_task(1)",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "Completing task #1 automatically unblocks task #2"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Task #1 completed. Task #2 is now ready.",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "File-based persistence outlives process memory"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Database schema is done. Moving to user model implementation.",
|
||||
"annotation": "Dependency graph ensures tasks execute in correct order"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": "s08",
|
||||
"title": "Background Tasks",
|
||||
"description": "Agent executes long-running tasks in background threads",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Run the test suite and while it's running, fix the linting errors",
|
||||
"annotation": "Two tasks that can run concurrently"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "I'll start the tests in the background and fix linting in parallel.",
|
||||
"annotation": "Agent recognizes opportunity for concurrent execution"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "pytest tests/ -v --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",
|
||||
"annotation": "Thread is running independently -- main loop continues"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Background task started: task_001",
|
||||
"toolName": "background_task",
|
||||
"annotation": "Control returns immediately to the agent"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "ruff check --fix .",
|
||||
"toolName": "bash",
|
||||
"annotation": "Agent works on linting while tests run in background"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "Fixed 12 linting errors in 5 files",
|
||||
"toolName": "bash",
|
||||
"annotation": "Linting 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",
|
||||
"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.",
|
||||
"annotation": "Non-blocking execution via threads + queue enables parallel work"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "s09",
|
||||
"title": "Agent Teams",
|
||||
"description": "Persistent teammates communicate via JSONL inboxes",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Build a full-stack app: frontend in React, backend in Python, 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)",
|
||||
"toolName": "teammate_manager",
|
||||
"annotation": "Unlike subagents (s04) that die after one task, teammates persist"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[TeammateManager] Team created:\n .team/config.json written\n frontend-dev [idle] - own thread, own agent loop\n backend-dev [idle] - own thread, own agent loop",
|
||||
"annotation": "Each teammate runs its own while(tool_use) loop in a separate thread"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "send_message(\n to: \"backend-dev\",\n content: \"Build the user API: GET/POST /users\"\n)",
|
||||
"toolName": "send_message",
|
||||
"annotation": "Message appended to .team/inbox/backend-dev.jsonl"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[backend-dev] Checking inbox... 1 new message\n From: lead\n Content: \"Build the user API: GET/POST /users\"\n (inbox drained after read)",
|
||||
"annotation": "Inbox is read-then-clear: prevents duplicate processing"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[backend-dev -> frontend-dev] Reply via inbox:\n \"API ready: GET /users, POST /users, GET /users/:id\"\n Written to .team/inbox/frontend-dev.jsonl",
|
||||
"annotation": "Teammates message each other through the same JSONL mechanism"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Team is communicating via file-based inboxes. Each teammate has its own .jsonl file -- append to send, drain to read.",
|
||||
"annotation": "JSONL inboxes decouple sender timing from receiver timing"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": "s10",
|
||||
"title": "Team Protocols",
|
||||
"description": "Request-response protocols for shutdown and plan approval",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Work is done. Shut down the team gracefully.",
|
||||
"annotation": "Can't just kill threads -- need coordinated shutdown"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "shutdown_request(\n teammate: \"backend-dev\",\n request_id: \"req_abc123\"\n)",
|
||||
"toolName": "shutdown_teammate",
|
||||
"annotation": "request_id correlates the request with the response"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[Shutdown FSM] req_abc123: pending\n Lead -> backend-dev inbox:\n { type: \"shutdown_request\", request_id: \"req_abc123\" }",
|
||||
"annotation": "FSM tracks state: pending -> approved | rejected"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[backend-dev] Received shutdown request req_abc123\n Current task: none (idle)\n Decision: approve\n -> lead inbox: { type: \"shutdown_response\", request_id: \"req_abc123\", approve: true }",
|
||||
"annotation": "Teammate decides whether to approve based on its own state"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[Shutdown FSM] req_abc123: approved\n backend-dev thread stopped\n Status: idle -> shutdown",
|
||||
"annotation": "Same request_id pattern works for plan approval too"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "backend-dev shut down gracefully. The same request_id + FSM pattern handles plan approval: teammate submits plan, lead approves/rejects with the correlated request_id.",
|
||||
"annotation": "One pattern (request_id correlation), two applications (shutdown + plan approval)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "s11",
|
||||
"title": "Autonomous Agents",
|
||||
"description": "Teammates self-govern with idle cycles and auto-claiming",
|
||||
"steps": [
|
||||
{
|
||||
"type": "user_message",
|
||||
"content": "Implement the full feature backlog",
|
||||
"annotation": "Open-ended task for autonomous team execution"
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "create_tasks([\n { title: \"User authentication\", status: \"open\" },\n { title: \"Dashboard UI\", status: \"open\" },\n { title: \"API rate limiting\", status: \"open\" },\n { title: \"Integration tests\", status: \"open\" }\n])",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "Tasks created with no owner -- available for auto-claiming"
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "4 tasks created on shared board",
|
||||
"toolName": "task_manager",
|
||||
"annotation": "Unassigned tasks visible to all teammates"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[frontend-dev] Idle cycle triggered\n 1. Check inbox -> 0 messages\n 2. Poll task board -> found unowned task #2 \"Dashboard UI\"\n 3. Auto-claim task #2\n 4. Status: idle -> working",
|
||||
"annotation": "Idle cycle: check inbox, poll tasks, auto-claim, resume work"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[backend-dev] Idle cycle triggered\n 1. Check inbox -> 0 messages\n 2. Poll task board -> found unowned task #1 \"User authentication\"\n 3. Auto-claim task #1\n 4. Status: idle -> working",
|
||||
"annotation": "Multiple teammates claim different tasks concurrently"
|
||||
},
|
||||
{
|
||||
"type": "system_event",
|
||||
"content": "[tester] Idle cycle triggered\n 1. Check inbox -> 0 messages\n 2. Poll task board -> task #4 blocked by #1, #2, #3\n 3. No claimable tasks\n 4. Status: idle (will retry in 30s)",
|
||||
"annotation": "Timeout-based polling prevents busy-waiting"
|
||||
},
|
||||
{
|
||||
"type": "assistant_text",
|
||||
"content": "Team is self-organizing: frontend-dev claimed Dashboard UI, backend-dev claimed User auth. Tester is waiting for dependencies to clear.",
|
||||
"annotation": "Polling + timeout makes teammates autonomous -- no micromanagement needed"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDarkMode(): boolean {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
setIsDark(html.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(html.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(html, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
export interface SvgPalette {
|
||||
nodeFill: string;
|
||||
nodeStroke: string;
|
||||
nodeText: string;
|
||||
activeNodeFill: string;
|
||||
activeNodeStroke: string;
|
||||
activeNodeText: string;
|
||||
endNodeFill: string;
|
||||
endNodeStroke: string;
|
||||
edgeStroke: string;
|
||||
activeEdgeStroke: string;
|
||||
arrowFill: string;
|
||||
labelFill: string;
|
||||
bgSubtle: string;
|
||||
}
|
||||
|
||||
export function useSvgPalette(): SvgPalette {
|
||||
const isDark = useDarkMode();
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
nodeFill: "#27272a",
|
||||
nodeStroke: "#3f3f46",
|
||||
nodeText: "#d4d4d8",
|
||||
activeNodeFill: "#3b82f6",
|
||||
activeNodeStroke: "#2563eb",
|
||||
activeNodeText: "#ffffff",
|
||||
endNodeFill: "#a855f7",
|
||||
endNodeStroke: "#9333ea",
|
||||
edgeStroke: "#52525b",
|
||||
activeEdgeStroke: "#3b82f6",
|
||||
arrowFill: "#71717a",
|
||||
labelFill: "#a1a1aa",
|
||||
bgSubtle: "#18181b",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nodeFill: "#e2e8f0",
|
||||
nodeStroke: "#cbd5e1",
|
||||
nodeText: "#475569",
|
||||
activeNodeFill: "#3b82f6",
|
||||
activeNodeStroke: "#2563eb",
|
||||
activeNodeText: "#ffffff",
|
||||
endNodeFill: "#a855f7",
|
||||
endNodeStroke: "#9333ea",
|
||||
edgeStroke: "#cbd5e1",
|
||||
activeEdgeStroke: "#3b82f6",
|
||||
arrowFill: "#94a3b8",
|
||||
labelFill: "#94a3b8",
|
||||
bgSubtle: "#f8fafc",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { SimStep } from "@/types/agent-data";
|
||||
|
||||
interface SimulatorState {
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export function useSimulator(steps: SimStep[]) {
|
||||
const [state, setState] = useState<SimulatorState>({
|
||||
currentIndex: -1,
|
||||
isPlaying: false,
|
||||
speed: 1,
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stepForward = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (prev.currentIndex >= steps.length - 1) {
|
||||
return { ...prev, isPlaying: false };
|
||||
}
|
||||
return { ...prev, currentIndex: prev.currentIndex + 1 };
|
||||
});
|
||||
}, [steps.length]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (prev.currentIndex >= steps.length - 1) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, isPlaying: true };
|
||||
});
|
||||
}, [steps.length]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
clearTimer();
|
||||
setState((prev) => ({ ...prev, isPlaying: false }));
|
||||
}, [clearTimer]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimer();
|
||||
setState({ currentIndex: -1, isPlaying: false, speed: state.speed });
|
||||
}, [clearTimer, state.speed]);
|
||||
|
||||
const setSpeed = useCallback((speed: number) => {
|
||||
setState((prev) => ({ ...prev, speed }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isPlaying && state.currentIndex < steps.length - 1) {
|
||||
const delay = 1200 / state.speed;
|
||||
timerRef.current = setTimeout(() => {
|
||||
stepForward();
|
||||
}, delay);
|
||||
} else if (state.isPlaying && state.currentIndex >= steps.length - 1) {
|
||||
setState((prev) => ({ ...prev, isPlaying: false }));
|
||||
}
|
||||
return () => clearTimer();
|
||||
}, [state.isPlaying, state.currentIndex, state.speed, steps.length, stepForward, clearTimer]);
|
||||
|
||||
return {
|
||||
currentIndex: state.currentIndex,
|
||||
isPlaying: state.isPlaying,
|
||||
speed: state.speed,
|
||||
visibleSteps: steps.slice(0, state.currentIndex + 1),
|
||||
totalSteps: steps.length,
|
||||
isComplete: state.currentIndex >= steps.length - 1,
|
||||
play,
|
||||
pause,
|
||||
stepForward,
|
||||
reset,
|
||||
setSpeed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface SteppedVisualizationOptions {
|
||||
totalSteps: number;
|
||||
autoPlayInterval?: number; // ms, default 2000
|
||||
}
|
||||
|
||||
interface SteppedVisualizationReturn {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
reset: () => void;
|
||||
goToStep: (step: number) => void;
|
||||
isPlaying: boolean;
|
||||
toggleAutoPlay: () => void;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
}
|
||||
|
||||
export function useSteppedVisualization({
|
||||
totalSteps,
|
||||
autoPlayInterval = 2000,
|
||||
}: SteppedVisualizationOptions): SteppedVisualizationReturn {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1));
|
||||
}, [totalSteps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: number) => {
|
||||
setCurrentStep(Math.max(0, Math.min(step, totalSteps - 1)));
|
||||
},
|
||||
[totalSteps]
|
||||
);
|
||||
|
||||
const toggleAutoPlay = useCallback(() => {
|
||||
setIsPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setCurrentStep((prev) => {
|
||||
if (prev >= totalSteps - 1) {
|
||||
setIsPlaying(false);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, autoPlayInterval);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [isPlaying, totalSteps, autoPlayInterval]);
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
goToStep,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
isFirstStep: currentStep === 0,
|
||||
isLastStep: currentStep === totalSteps - 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "Build an AI coding agent from scratch, one concept at a time" },
|
||||
"nav": { "home": "Home", "timeline": "Timeline", "compare": "Compare", "layers": "Layers", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "Build an AI coding agent from scratch, one concept at a time", "start": "Start Learning", "core_pattern": "The Core Pattern", "core_pattern_desc": "Every AI coding agent shares the same loop: call the model, execute tools, feed results back. Everything else is details.", "learning_path": "Learning Path", "learning_path_desc": "11 progressive sessions, from a simple loop to full autonomous teams", "layers_title": "Architectural Layers", "layers_desc": "Five orthogonal concerns that compose into a complete agent", "loc": "LOC", "learn_more": "Learn More", "versions_in_layer": "versions", "message_flow": "Message Growth", "message_flow_desc": "Watch the messages array grow as the agent loop executes" },
|
||||
"version": { "loc": "lines of code", "tools": "tools", "new": "New", "prev": "Previous", "next": "Next", "view_source": "View Source", "view_diff": "View Diff", "design_decisions": "Design Decisions", "whats_new": "What's New", "tutorial": "Tutorial", "simulator": "Agent Loop Simulator", "execution_flow": "Execution Flow", "architecture": "Architecture", "concept_viz": "Concept Visualization", "alternatives": "Alternatives Considered", "tab_learn": "Learn", "tab_simulate": "Simulate", "tab_code": "Code", "tab_deep_dive": "Deep Dive" },
|
||||
"sim": { "play": "Play", "pause": "Pause", "step": "Step", "reset": "Reset", "speed": "Speed", "step_of": "of" },
|
||||
"timeline": { "title": "Learning Path", "subtitle": "s01 to s11: Progressive Agent Design", "layer_legend": "Layer Legend", "loc_growth": "LOC Growth", "learn_more": "Learn More" },
|
||||
"layers": {
|
||||
"title": "Architectural Layers",
|
||||
"subtitle": "Five orthogonal concerns that compose into a complete agent",
|
||||
"tools": "What the agent CAN do. The foundation: tools give the model capabilities to interact with the world.",
|
||||
"planning": "How work is organized. From simple todo lists to dependency-aware task boards shared across agents.",
|
||||
"memory": "Keeping context within limits. Compression strategies that let agents work infinitely without losing coherence.",
|
||||
"concurrency": "Non-blocking execution. Background threads and notification buses for parallel work.",
|
||||
"collaboration": "Multi-agent coordination. Teams, messaging, and autonomous teammates that think for themselves."
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compare Versions",
|
||||
"subtitle": "See what changed between any two versions",
|
||||
"select_a": "Version A",
|
||||
"select_b": "Version B",
|
||||
"loc_delta": "LOC Delta",
|
||||
"lines": "lines",
|
||||
"new_tools_in_b": "New Tools in B",
|
||||
"new_classes_in_b": "New Classes in B",
|
||||
"new_functions_in_b": "New Functions in B",
|
||||
"tool_comparison": "Tool Comparison",
|
||||
"only_in": "Only in",
|
||||
"shared": "Shared",
|
||||
"none": "None",
|
||||
"source_diff": "Source Code Diff",
|
||||
"empty_hint": "Select two versions above to compare them.",
|
||||
"architecture": "Architecture"
|
||||
},
|
||||
"diff": {
|
||||
"new_classes": "New Classes",
|
||||
"new_tools": "New Tools",
|
||||
"new_functions": "New Functions",
|
||||
"loc_delta": "LOC Delta"
|
||||
},
|
||||
"sessions": {
|
||||
"s01": "The Agent Loop",
|
||||
"s02": "Tools",
|
||||
"s03": "TodoWrite",
|
||||
"s04": "Subagents",
|
||||
"s05": "Skills",
|
||||
"s06": "Compact",
|
||||
"s07": "Tasks",
|
||||
"s08": "Background Tasks",
|
||||
"s09": "Agent Teams",
|
||||
"s10": "Team Protocols",
|
||||
"s11": "Autonomous Agents"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "Tools & Execution",
|
||||
"planning": "Planning & Coordination",
|
||||
"memory": "Memory Management",
|
||||
"concurrency": "Concurrency",
|
||||
"collaboration": "Collaboration"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "The Agent While-Loop",
|
||||
"s02": "Tool Dispatch Map",
|
||||
"s03": "TodoWrite Nag System",
|
||||
"s04": "Subagent Context Isolation",
|
||||
"s05": "On-Demand Skill Loading",
|
||||
"s06": "Three-Layer Context Compression",
|
||||
"s07": "Task Dependency Graph",
|
||||
"s08": "Background Task Lanes",
|
||||
"s09": "Agent Team Mailboxes",
|
||||
"s10": "FSM Team Protocols",
|
||||
"s11": "Autonomous Agent Cycle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "AIコーディングエージェントをゼロから構築、一つずつ概念を追加" },
|
||||
"nav": { "home": "ホーム", "timeline": "学習パス", "compare": "バージョン比較", "layers": "アーキテクチャ層", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "AIコーディングエージェントをゼロから構築、一つずつ概念を追加", "start": "学習を始める", "core_pattern": "コアパターン", "core_pattern_desc": "全てのAIコーディングエージェントは同じループを共有:モデルを呼び出し、ツールを実行し、結果をフィードバック。他は全て詳細。", "learning_path": "学習パス", "learning_path_desc": "11の段階的セッション、シンプルなループから完全自律チームまで", "layers_title": "アーキテクチャ層", "layers_desc": "5つの直交する関心事が完全なエージェントを構成", "loc": "行", "learn_more": "詳細を見る", "versions_in_layer": "バージョン", "message_flow": "メッセージの増加", "message_flow_desc": "エージェントループ実行時のメッセージ配列の成長を観察" },
|
||||
"version": { "loc": "行のコード", "tools": "ツール", "new": "新規", "prev": "前のバージョン", "next": "次のバージョン", "view_source": "ソースを見る", "view_diff": "差分を見る", "design_decisions": "設計判断", "whats_new": "新機能", "tutorial": "チュートリアル", "simulator": "エージェントループシミュレーター", "execution_flow": "実行フロー", "architecture": "アーキテクチャ", "concept_viz": "コンセプト可視化", "alternatives": "検討された代替案", "tab_learn": "学習", "tab_simulate": "シミュレーション", "tab_code": "ソースコード", "tab_deep_dive": "詳細分析" },
|
||||
"sim": { "play": "再生", "pause": "一時停止", "step": "ステップ", "reset": "リセット", "speed": "速度", "step_of": "/" },
|
||||
"timeline": { "title": "学習パス", "subtitle": "s01からs11へ:段階的エージェント設計", "layer_legend": "レイヤー凡例", "loc_growth": "コード量の推移", "learn_more": "詳細を見る" },
|
||||
"layers": {
|
||||
"title": "アーキテクチャ層",
|
||||
"subtitle": "5つの直交する関心事が完全なエージェントを構成",
|
||||
"tools": "エージェントができること。基盤:ツールがモデルに外部世界と対話する能力を与える。",
|
||||
"planning": "作業の組織化。シンプルなToDoリストからエージェント間で共有される依存関係対応タスクボードまで。",
|
||||
"memory": "コンテキスト制限内での記憶保持。圧縮戦略によりエージェントが一貫性を失わずに無限に作業可能。",
|
||||
"concurrency": "ノンブロッキング実行。バックグラウンドスレッドと通知バスによる並列作業。",
|
||||
"collaboration": "マルチエージェント連携。チーム、メッセージング、自律的に考えるチームメイト。"
|
||||
},
|
||||
"compare": {
|
||||
"title": "バージョン比較",
|
||||
"subtitle": "任意の2つのバージョン間の変更を確認",
|
||||
"select_a": "バージョンA",
|
||||
"select_b": "バージョンB",
|
||||
"loc_delta": "コード量の差分",
|
||||
"lines": "行",
|
||||
"new_tools_in_b": "Bの新規ツール",
|
||||
"new_classes_in_b": "Bの新規クラス",
|
||||
"new_functions_in_b": "Bの新規関数",
|
||||
"tool_comparison": "ツール比較",
|
||||
"only_in": "のみ",
|
||||
"shared": "共通",
|
||||
"none": "なし",
|
||||
"source_diff": "ソースコード差分",
|
||||
"empty_hint": "上で2つのバージョンを選択して比較してください。",
|
||||
"architecture": "アーキテクチャ"
|
||||
},
|
||||
"diff": {
|
||||
"new_classes": "新規クラス",
|
||||
"new_tools": "新規ツール",
|
||||
"new_functions": "新規関数",
|
||||
"loc_delta": "コード量の差分"
|
||||
},
|
||||
"sessions": {
|
||||
"s01": "エージェントループ",
|
||||
"s02": "ツール",
|
||||
"s03": "TodoWrite",
|
||||
"s04": "サブエージェント",
|
||||
"s05": "スキル",
|
||||
"s06": "コンテキスト圧縮",
|
||||
"s07": "タスクシステム",
|
||||
"s08": "バックグラウンドタスク",
|
||||
"s09": "エージェントチーム",
|
||||
"s10": "チームプロトコル",
|
||||
"s11": "自律エージェント"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "ツールと実行",
|
||||
"planning": "計画と調整",
|
||||
"memory": "メモリ管理",
|
||||
"concurrency": "並行処理",
|
||||
"collaboration": "コラボレーション"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "エージェント Whileループ",
|
||||
"s02": "ツールディスパッチマップ",
|
||||
"s03": "TodoWrite リマインドシステム",
|
||||
"s04": "サブエージェント コンテキスト分離",
|
||||
"s05": "オンデマンド スキルローディング",
|
||||
"s06": "3層コンテキスト圧縮",
|
||||
"s07": "タスク依存関係グラフ",
|
||||
"s08": "バックグラウンドタスクレーン",
|
||||
"s09": "エージェントチーム メールボックス",
|
||||
"s10": "FSM チームプロトコル",
|
||||
"s11": "自律エージェントサイクル"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"meta": { "title": "Learn Claude Code", "description": "从零构建 AI 编程 Agent,每次只加一个概念" },
|
||||
"nav": { "home": "首页", "timeline": "学习路径", "compare": "版本对比", "layers": "架构层", "github": "GitHub" },
|
||||
"home": { "hero_title": "Learn Claude Code", "hero_subtitle": "从零构建 AI 编程 Agent,每次只加一个概念", "start": "开始学习", "core_pattern": "核心模式", "core_pattern_desc": "所有 AI 编程 Agent 共享同一个循环:调用模型、执行工具、回传结果。其他都是细节。", "learning_path": "学习路径", "learning_path_desc": "11 个渐进式课程,从简单循环到完整自主团队", "layers_title": "架构层次", "layers_desc": "五个正交关注点组合成完整的 Agent", "loc": "行", "learn_more": "了解更多", "versions_in_layer": "个版本", "message_flow": "消息增长", "message_flow_desc": "观察 Agent 循环执行时消息数组的增长" },
|
||||
"version": { "loc": "行代码", "tools": "个工具", "new": "新增", "prev": "上一版", "next": "下一版", "view_source": "查看源码", "view_diff": "查看变更", "design_decisions": "设计决策", "whats_new": "新增内容", "tutorial": "教程", "simulator": "Agent 循环模拟器", "execution_flow": "执行流程", "architecture": "架构", "concept_viz": "概念可视化", "alternatives": "替代方案", "tab_learn": "学习", "tab_simulate": "模拟", "tab_code": "源码", "tab_deep_dive": "深入探索" },
|
||||
"sim": { "play": "播放", "pause": "暂停", "step": "单步", "reset": "重置", "speed": "速度", "step_of": "/" },
|
||||
"timeline": { "title": "学习路径", "subtitle": "s01 到 s11:渐进式 Agent 设计", "layer_legend": "层次图例", "loc_growth": "代码量增长", "learn_more": "了解更多" },
|
||||
"layers": {
|
||||
"title": "架构层次",
|
||||
"subtitle": "五个正交关注点组合成完整的 Agent",
|
||||
"tools": "Agent 能做什么。基础层:工具赋予模型与外部世界交互的能力。",
|
||||
"planning": "如何组织工作。从简单的待办列表到跨 Agent 共享的依赖感知任务板。",
|
||||
"memory": "在上下文限制内保持记忆。压缩策略让 Agent 可以无限工作而不失去连贯性。",
|
||||
"concurrency": "非阻塞执行。后台线程和通知总线实现并行工作。",
|
||||
"collaboration": "多 Agent 协作。团队、消息传递和能独立思考的自主队友。"
|
||||
},
|
||||
"compare": {
|
||||
"title": "版本对比",
|
||||
"subtitle": "查看任意两个版本之间的变化",
|
||||
"select_a": "版本 A",
|
||||
"select_b": "版本 B",
|
||||
"loc_delta": "代码量差异",
|
||||
"lines": "行",
|
||||
"new_tools_in_b": "B 中新增工具",
|
||||
"new_classes_in_b": "B 中新增类",
|
||||
"new_functions_in_b": "B 中新增函数",
|
||||
"tool_comparison": "工具对比",
|
||||
"only_in": "仅在",
|
||||
"shared": "共有",
|
||||
"none": "无",
|
||||
"source_diff": "源码差异",
|
||||
"empty_hint": "请在上方选择两个版本进行对比。",
|
||||
"architecture": "架构"
|
||||
},
|
||||
"diff": {
|
||||
"new_classes": "新增类",
|
||||
"new_tools": "新增工具",
|
||||
"new_functions": "新增函数",
|
||||
"loc_delta": "代码量差异"
|
||||
},
|
||||
"sessions": {
|
||||
"s01": "Agent 循环",
|
||||
"s02": "工具",
|
||||
"s03": "TodoWrite",
|
||||
"s04": "子 Agent",
|
||||
"s05": "技能",
|
||||
"s06": "上下文压缩",
|
||||
"s07": "任务系统",
|
||||
"s08": "后台任务",
|
||||
"s09": "Agent 团队",
|
||||
"s10": "团队协议",
|
||||
"s11": "自主 Agent"
|
||||
},
|
||||
"layer_labels": {
|
||||
"tools": "工具与执行",
|
||||
"planning": "规划与协调",
|
||||
"memory": "内存管理",
|
||||
"concurrency": "并发",
|
||||
"collaboration": "协作"
|
||||
},
|
||||
"viz": {
|
||||
"s01": "Agent While 循环",
|
||||
"s02": "工具分发映射",
|
||||
"s03": "TodoWrite 提醒系统",
|
||||
"s04": "子 Agent 上下文隔离",
|
||||
"s05": "按需技能加载",
|
||||
"s06": "三层上下文压缩",
|
||||
"s07": "任务依赖图",
|
||||
"s08": "后台任务通道",
|
||||
"s09": "Agent 团队邮箱",
|
||||
"s10": "FSM 团队协议",
|
||||
"s11": "自主 Agent 循环"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const VERSION_ORDER = [
|
||||
"s01", "s02", "s03", "s04", "s05", "s06", "s07", "s08", "s09", "s10", "s11"
|
||||
] as const;
|
||||
|
||||
export const LEARNING_PATH = VERSION_ORDER;
|
||||
|
||||
export type VersionId = typeof LEARNING_PATH[number];
|
||||
|
||||
export const VERSION_META: Record<string, {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
coreAddition: string;
|
||||
keyInsight: string;
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration";
|
||||
prevVersion: string | null;
|
||||
}> = {
|
||||
s01: { title: "The Agent Loop", subtitle: "Bash is All You Need", coreAddition: "Single-tool agent loop", keyInsight: "The entire agent is a while loop + one tool", layer: "tools", prevVersion: null },
|
||||
s02: { title: "Tools", subtitle: "The Loop Didn't Change", coreAddition: "Tool dispatch map", keyInsight: "Adding tools means adding handlers, the loop stays the same", layer: "tools", prevVersion: "s01" },
|
||||
s03: { title: "TodoWrite", subtitle: "Plan Before You Act", coreAddition: "TodoManager + nag reminder", keyInsight: "Visible plans improve task completion and accountability", layer: "planning", prevVersion: "s02" },
|
||||
s04: { title: "Subagents", subtitle: "Fresh Context via Task Tool", coreAddition: "Subagent spawn with isolated messages[]", keyInsight: "Process isolation = context isolation", layer: "planning", prevVersion: "s03" },
|
||||
s05: { title: "Skills", subtitle: "SKILL.md + tool_result Injection", coreAddition: "SkillLoader + two-layer injection", keyInsight: "Skills inject via tool_result, not system prompt", layer: "planning", prevVersion: "s04" },
|
||||
s06: { title: "Compact", subtitle: "Strategic Forgetting", coreAddition: "micro-compact + auto-compact + archival", keyInsight: "Forgetting old context enables infinite-length sessions", layer: "memory", prevVersion: "s05" },
|
||||
s07: { title: "Tasks", subtitle: "Persistent CRUD with Dependencies", coreAddition: "TaskManager with file-based state + dependency graph", keyInsight: "File-based state survives context compression", layer: "planning", prevVersion: "s06" },
|
||||
s08: { title: "Background Tasks", subtitle: "Fire and Forget", coreAddition: "BackgroundManager + notification queue", keyInsight: "Non-blocking daemon threads + notification queue", layer: "concurrency", prevVersion: "s07" },
|
||||
s09: { title: "Agent Teams", subtitle: "Teammates + Mailboxes", coreAddition: "TeammateManager + file-based mailbox", keyInsight: "Persistent teammates with async mailbox inboxes", layer: "collaboration", prevVersion: "s08" },
|
||||
s10: { title: "Team Protocols", subtitle: "Shutdown + Plan Approval", coreAddition: "request_id correlation for two protocols", keyInsight: "Same request-response pattern, two applications", layer: "collaboration", prevVersion: "s09" },
|
||||
s11: { title: "Autonomous Agents", subtitle: "Idle Cycle + Auto-Claim", coreAddition: "Task board polling + timeout-based self-governance", keyInsight: "Polling + timeout makes teammates self-organizing", layer: "collaboration", prevVersion: "s10" },
|
||||
};
|
||||
|
||||
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"] },
|
||||
{ id: "memory" as const, label: "Memory Management", color: "#8B5CF6", versions: ["s06"] },
|
||||
{ id: "concurrency" as const, label: "Concurrency", color: "#F59E0B", versions: ["s08"] },
|
||||
{ id: "collaboration" as const, label: "Collaboration", color: "#EF4444", versions: ["s09", "s10", "s11"] },
|
||||
] as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import en from "@/i18n/messages/en.json";
|
||||
import zh from "@/i18n/messages/zh.json";
|
||||
import ja from "@/i18n/messages/ja.json";
|
||||
|
||||
type Messages = typeof en;
|
||||
|
||||
const messagesMap: Record<string, Messages> = { en, zh, ja };
|
||||
|
||||
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];
|
||||
return (key: string): string => {
|
||||
return ns?.[key] || fallbackNs?.[key] || key;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { createContext, useContext, ReactNode } from "react";
|
||||
import en from "@/i18n/messages/en.json";
|
||||
import zh from "@/i18n/messages/zh.json";
|
||||
import ja from "@/i18n/messages/ja.json";
|
||||
|
||||
type Messages = typeof en;
|
||||
|
||||
const messagesMap: Record<string, Messages> = { en, zh, ja };
|
||||
|
||||
const I18nContext = createContext<{ locale: string; messages: Messages }>({
|
||||
locale: "en",
|
||||
messages: en,
|
||||
});
|
||||
|
||||
export function I18nProvider({ locale, children }: { locale: string; children: ReactNode }) {
|
||||
const messages = messagesMap[locale] || en;
|
||||
return (
|
||||
<I18nContext.Provider value={{ locale, messages }}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTranslations(namespace?: string) {
|
||||
const { messages } = useContext(I18nContext);
|
||||
return (key: string) => {
|
||||
const ns = namespace ? (messages as any)[namespace] : messages;
|
||||
if (!ns) return key;
|
||||
return (ns as any)[key] || key;
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(I18nContext).locale;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: (string | undefined | null | false)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export interface AgentVersion {
|
||||
id: string;
|
||||
filename: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
loc: number;
|
||||
tools: string[];
|
||||
newTools: string[];
|
||||
coreAddition: string;
|
||||
keyInsight: string;
|
||||
classes: { name: string; startLine: number; endLine: number }[];
|
||||
functions: { name: string; signature: string; startLine: number }[];
|
||||
layer: "tools" | "planning" | "memory" | "concurrency" | "collaboration";
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface VersionDiff {
|
||||
from: string;
|
||||
to: string;
|
||||
newClasses: string[];
|
||||
newFunctions: string[];
|
||||
newTools: string[];
|
||||
locDelta: number;
|
||||
}
|
||||
|
||||
export interface DocContent {
|
||||
version: string;
|
||||
locale: "en" | "zh" | "ja";
|
||||
title: string;
|
||||
content: string; // raw markdown
|
||||
}
|
||||
|
||||
export interface VersionIndex {
|
||||
versions: AgentVersion[];
|
||||
diffs: VersionDiff[];
|
||||
}
|
||||
|
||||
export type SimStepType =
|
||||
| "user_message"
|
||||
| "assistant_text"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "system_event";
|
||||
|
||||
export interface SimStep {
|
||||
type: SimStepType;
|
||||
content: string;
|
||||
annotation: string;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
version: string;
|
||||
title: string;
|
||||
description: string;
|
||||
steps: SimStep[];
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "start" | "process" | "decision" | "subprocess" | "end";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
}
|
||||
Reference in new issue
Block a user