mirror of
https://github.com/rohitg00/ai-engineering-from-scratch.git
synced 2026-10-02 01:54:39 +08:00
Merge pull request #184 from rohitg00/feat/typescript-coverage-pass-3
feat: TypeScript coverage pass 3 (protocols + agent engineering)
This commit is contained in:
+12
-6
@@ -7246,7 +7246,8 @@
|
||||
"has_quiz": false,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
@@ -7416,7 +7417,8 @@
|
||||
"has_quiz": false,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
@@ -7765,7 +7767,8 @@
|
||||
"has_quiz": false,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
@@ -7917,7 +7920,8 @@
|
||||
"has_quiz": true,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
@@ -8062,7 +8066,8 @@
|
||||
"has_quiz": true,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
@@ -8268,7 +8273,8 @@
|
||||
"has_quiz": true,
|
||||
"has_notebook": true,
|
||||
"code_files": [
|
||||
"main.py"
|
||||
"main.py",
|
||||
"main.ts"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Phase 13 Lesson 01 — the tool interface, in TypeScript.
|
||||
//
|
||||
// Mirrors code/main.py: describe -> decide -> execute -> observe.
|
||||
// The "decide" step is faked with a keyword router so the loop runs offline;
|
||||
// replace with any real provider client and the shape stays the same.
|
||||
//
|
||||
// Spec references:
|
||||
// OpenAI tool calling https://platform.openai.com/docs/guides/function-calling
|
||||
// Anthropic tool use https://docs.anthropic.com/en/docs/build-with-claude/tool-use
|
||||
// MCP tool primitive https://modelcontextprotocol.io/specification/2025-11-25
|
||||
//
|
||||
// Run: npx tsx code/main.ts
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const MAX_TURNS = 5;
|
||||
|
||||
type JsonSchema = {
|
||||
type?: "object" | "string" | "number" | "integer" | "boolean" | "array";
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
enum?: unknown[];
|
||||
};
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
type ToolResult = Record<string, unknown>;
|
||||
|
||||
type Tool = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema;
|
||||
executor: (args: ToolArgs) => ToolResult;
|
||||
consequential?: boolean;
|
||||
};
|
||||
|
||||
type HistoryEntry =
|
||||
| { role: "user"; content: string }
|
||||
| { role: "tool"; id: string; name: string; content: string };
|
||||
|
||||
type ToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: ToolArgs;
|
||||
};
|
||||
|
||||
type Decision = { content: string } | { toolCalls: ToolCall[] };
|
||||
|
||||
function toolAdd(args: ToolArgs): ToolResult {
|
||||
const a = args.a as number;
|
||||
const b = args.b as number;
|
||||
return { sum: a + b };
|
||||
}
|
||||
|
||||
function toolGetTime(args: ToolArgs): ToolResult {
|
||||
const timezone = (args.timezone as string | undefined) ?? "UTC";
|
||||
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
return { now, timezone };
|
||||
}
|
||||
|
||||
function toolGetWeather(args: ToolArgs): ToolResult {
|
||||
const fake: Record<string, number> = {
|
||||
Bengaluru: 28,
|
||||
Tokyo: 12,
|
||||
Zurich: 4,
|
||||
Lagos: 31,
|
||||
};
|
||||
const city = args.city as string;
|
||||
const units = (args.units as string | undefined) ?? "celsius";
|
||||
const temp = fake[city] ?? 20;
|
||||
return { city, temp, units };
|
||||
}
|
||||
|
||||
const REGISTRY: Tool[] = [
|
||||
{
|
||||
name: "add",
|
||||
description:
|
||||
"Use when the user asks for the sum of two numbers. " +
|
||||
"Do not use for subtraction, product, or symbolic algebra.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { type: "number" },
|
||||
b: { type: "number" },
|
||||
},
|
||||
required: ["a", "b"],
|
||||
},
|
||||
executor: toolAdd,
|
||||
},
|
||||
{
|
||||
name: "get_time",
|
||||
description:
|
||||
"Use when the user asks what time it is. " +
|
||||
"Do not use for historical dates or future scheduling.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timezone: { type: "string" },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
executor: toolGetTime,
|
||||
},
|
||||
{
|
||||
name: "get_weather",
|
||||
description:
|
||||
"Use when the user asks about current conditions in a named city. " +
|
||||
"Do not use for forecasts or historical weather data.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
units: { type: "string", enum: ["celsius", "fahrenheit"] },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
executor: toolGetWeather,
|
||||
},
|
||||
];
|
||||
|
||||
function validate(schema: JsonSchema, value: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
const t = schema.type;
|
||||
|
||||
if (t === "object") {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return [`expected object, got ${describeType(value)}`];
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const field of schema.required ?? []) {
|
||||
if (!(field in obj)) errors.push(`missing required field '${field}'`);
|
||||
}
|
||||
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
|
||||
if (key in obj) errors.push(...validate(sub, obj[key]));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (t === "number" && typeof value !== "number") {
|
||||
errors.push(`expected number, got ${describeType(value)}`);
|
||||
}
|
||||
if (t === "string" && typeof value !== "string") {
|
||||
errors.push(`expected string, got ${describeType(value)}`);
|
||||
}
|
||||
if (schema.enum && !schema.enum.includes(value as never)) {
|
||||
errors.push(`value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function describeType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function newCallId(): string {
|
||||
return `call_${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// Stand-in for the model. Routes by keyword so the loop runs offline.
|
||||
// Production substitute: replace with a provider call returning the same shape.
|
||||
function fakeDecide(userMsg: string, history: HistoryEntry[]): Decision {
|
||||
const last = history[history.length - 1];
|
||||
if (last && last.role === "tool") {
|
||||
return { content: `Final answer built from tool output: ${last.content}` };
|
||||
}
|
||||
const msg = userMsg.toLowerCase();
|
||||
|
||||
if (/\b(add|sum|plus)\b/.test(msg)) {
|
||||
const nums = (msg.match(/-?\d+\.?\d*/g) ?? []).map((n) => Number(n));
|
||||
if (nums.length >= 2) {
|
||||
return {
|
||||
toolCalls: [
|
||||
{ id: newCallId(), name: "add", arguments: { a: nums[0], b: nums[1] } },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.includes("time")) {
|
||||
return {
|
||||
toolCalls: [
|
||||
{ id: newCallId(), name: "get_time", arguments: { timezone: "UTC" } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const weatherMatch = msg.match(/weather in (\w+)/);
|
||||
if (weatherMatch) {
|
||||
const city = weatherMatch[1][0].toUpperCase() + weatherMatch[1].slice(1);
|
||||
return {
|
||||
toolCalls: [
|
||||
{
|
||||
id: newCallId(),
|
||||
name: "get_weather",
|
||||
arguments: { city, units: "celsius" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { content: "I cannot route that query to any registered tool." };
|
||||
}
|
||||
|
||||
function runLoop(userMsg: string): void {
|
||||
console.log("=".repeat(72));
|
||||
console.log(`USER : ${userMsg}`);
|
||||
console.log("-".repeat(72));
|
||||
|
||||
const toolsByName = new Map(REGISTRY.map((t) => [t.name, t]));
|
||||
const history: HistoryEntry[] = [{ role: "user", content: userMsg }];
|
||||
|
||||
for (let turn = 1; turn <= MAX_TURNS; turn++) {
|
||||
const decision = fakeDecide(userMsg, history);
|
||||
|
||||
if ("content" in decision) {
|
||||
console.log(`TURN ${turn} DECIDE : final answer`);
|
||||
console.log(`MODEL : ${decision.content}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of decision.toolCalls) {
|
||||
const tool = toolsByName.get(call.name);
|
||||
console.log(`TURN ${turn} DECIDE : call ${call.name} id=${call.id}`);
|
||||
console.log(` args = ${JSON.stringify(call.arguments)}`);
|
||||
|
||||
if (!tool) {
|
||||
console.log(` ERROR : unknown tool ${call.name}`);
|
||||
return;
|
||||
}
|
||||
const errs = validate(tool.inputSchema, call.arguments);
|
||||
if (errs.length > 0) {
|
||||
console.log(` VALIDATION ERRORS : ${JSON.stringify(errs)}`);
|
||||
return;
|
||||
}
|
||||
if (tool.consequential) {
|
||||
console.log(" GATE : tool is consequential, would confirm");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const result = tool.executor(call.arguments);
|
||||
const ms = performance.now() - start;
|
||||
console.log(
|
||||
`TURN ${turn} EXECUTE: ${tool.name} -> ${JSON.stringify(result)} [${ms.toFixed(2)} ms]`,
|
||||
);
|
||||
history.push({
|
||||
role: "tool",
|
||||
id: call.id,
|
||||
name: tool.name,
|
||||
content: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
console.log(`TURN ${turn} OBSERVE: history length = ${history.length}`);
|
||||
}
|
||||
console.log("LOOP TERMINATED : hit MAX_TURNS circuit breaker");
|
||||
}
|
||||
|
||||
function describeRegistry(): void {
|
||||
console.log("TOOL REGISTRY");
|
||||
console.log("-".repeat(72));
|
||||
for (const t of REGISTRY) {
|
||||
const kind = t.consequential ? "consequential" : "pure";
|
||||
console.log(` ${t.name.padEnd(14)} [${kind}] - ${t.description}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log("=".repeat(72));
|
||||
console.log("PHASE 13 LESSON 01 - THE TOOL INTERFACE (TypeScript port)");
|
||||
console.log("=".repeat(72));
|
||||
describeRegistry();
|
||||
const queries = [
|
||||
"please add 7 and 35",
|
||||
"what time is it?",
|
||||
"tell me the weather in Bengaluru",
|
||||
"write me a haiku about tea",
|
||||
];
|
||||
for (const q of queries) {
|
||||
runLoop(q);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,356 @@
|
||||
// Phase 13 Lesson 07 — toy MCP server, in TypeScript, stdlib only.
|
||||
//
|
||||
// Implements the 2025-11-25 spec's core flow:
|
||||
// initialize, tools/list, tools/call, resources/list, resources/read,
|
||||
// prompts/list, prompts/get, plus notifications/initialized.
|
||||
//
|
||||
// Spec references:
|
||||
// MCP 2025-11-25 https://modelcontextprotocol.io/specification/2025-11-25
|
||||
// JSON-RPC 2.0 https://www.jsonrpc.org/specification
|
||||
//
|
||||
// Not a production server: no auth, no Streamable HTTP transport (Lesson 09),
|
||||
// no subscriptions. But the wire shape is spec-shaped; any MCP client can
|
||||
// handshake and call the three notes tools.
|
||||
//
|
||||
// Run demo: npx tsx code/main.ts --demo
|
||||
// Pipe JSON-RPC: echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | npx tsx code/main.ts
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const PROTOCOL_VERSION = "2025-11-25";
|
||||
const SERVER_INFO = { name: "notes-lesson-07", version: "1.0.0" };
|
||||
|
||||
type Note = { title: string; body: string; tag: string };
|
||||
|
||||
const NOTES: Record<string, Note> = {
|
||||
"note-1": { title: "MCP overview", body: "Primitives, lifecycle, JSON-RPC.", tag: "mcp" },
|
||||
"note-2": { title: "Function calling", body: "Provider shapes diff by envelope.", tag: "api" },
|
||||
"note-3": { title: "Tool schemas", body: "Atomic beats monolithic.", tag: "design" },
|
||||
};
|
||||
|
||||
type JsonSchema = {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
};
|
||||
|
||||
type ToolDescriptor = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: JsonSchema;
|
||||
annotations?: { readOnlyHint?: boolean; idempotentHint?: boolean; destructiveHint?: boolean };
|
||||
};
|
||||
|
||||
const TOOLS: ToolDescriptor[] = [
|
||||
{
|
||||
name: "notes_list",
|
||||
description:
|
||||
"Use when the user wants all notes or a filtered list by tag. Do not use to read a note body.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { tag: { type: "string" } },
|
||||
required: [],
|
||||
},
|
||||
annotations: { readOnlyHint: true, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: "notes_search",
|
||||
description:
|
||||
"Use when the user searches notes by content keywords. Do not use for tag filters.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
limit: { type: "integer", minimum: 1, maximum: 50 },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: "notes_create",
|
||||
description: "Use when the user writes a new note. Do not use to edit existing ones.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
tag: { type: "string" },
|
||||
},
|
||||
required: ["title", "body"],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
];
|
||||
|
||||
const PROMPTS = [
|
||||
{
|
||||
name: "review_note",
|
||||
description: "Produce a critique of a note with concrete improvements.",
|
||||
arguments: [
|
||||
{ name: "note_id", description: "The id of the note to review", required: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
type ContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "resource"; resource: { uri: string; text: string } };
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
|
||||
function execNotesList(args: ToolArgs): ContentBlock[] {
|
||||
const tag = args.tag as string | undefined;
|
||||
const items: Array<{ id: string; title: string; tag: string }> = [];
|
||||
for (const [id, note] of Object.entries(NOTES)) {
|
||||
if (tag && note.tag !== tag) continue;
|
||||
items.push({ id, title: note.title, tag: note.tag });
|
||||
}
|
||||
return [{ type: "text", text: JSON.stringify(items) }];
|
||||
}
|
||||
|
||||
function execNotesSearch(args: ToolArgs): ContentBlock[] {
|
||||
const q = String(args.query).toLowerCase();
|
||||
const limit = (args.limit as number | undefined) ?? 10;
|
||||
const hits: Array<{ id: string; title: string }> = [];
|
||||
for (const [id, n] of Object.entries(NOTES)) {
|
||||
if (n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q)) {
|
||||
hits.push({ id, title: n.title });
|
||||
}
|
||||
}
|
||||
return [{ type: "text", text: JSON.stringify(hits.slice(0, limit)) }];
|
||||
}
|
||||
|
||||
function execNotesCreate(args: ToolArgs): ContentBlock[] {
|
||||
const id = `note-${randomUUID().replace(/-/g, "").slice(0, 6)}`;
|
||||
const body = String(args.body);
|
||||
NOTES[id] = {
|
||||
title: String(args.title),
|
||||
body,
|
||||
tag: (args.tag as string | undefined) ?? "",
|
||||
};
|
||||
return [
|
||||
{ type: "text", text: `Created ${id}` },
|
||||
{ type: "resource", resource: { uri: `notes://${id}`, text: body } },
|
||||
];
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS: Record<string, (args: ToolArgs) => ContentBlock[]> = {
|
||||
notes_list: execNotesList,
|
||||
notes_search: execNotesSearch,
|
||||
notes_create: execNotesCreate,
|
||||
};
|
||||
|
||||
type JsonRpcRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id?: number | string | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type JsonRpcResponse = {
|
||||
jsonrpc: "2.0";
|
||||
id: number | string | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
};
|
||||
|
||||
function handleInitialize(): unknown {
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false, subscribe: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
serverInfo: SERVER_INFO,
|
||||
};
|
||||
}
|
||||
|
||||
function handleToolsList(): unknown {
|
||||
return { tools: TOOLS };
|
||||
}
|
||||
|
||||
function handleToolsCall(params: Record<string, unknown>): unknown {
|
||||
const name = params.name as string;
|
||||
const args = (params.arguments as ToolArgs | undefined) ?? {};
|
||||
const exec = TOOL_EXECUTORS[name];
|
||||
if (!exec) {
|
||||
return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true };
|
||||
}
|
||||
try {
|
||||
return { content: exec(args), isError: false };
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: String(err) }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourcesList(): unknown {
|
||||
const items = Object.entries(NOTES).map(([id, n]) => ({
|
||||
uri: `notes://${id}`,
|
||||
name: n.title,
|
||||
mimeType: "text/markdown",
|
||||
}));
|
||||
return { resources: items };
|
||||
}
|
||||
|
||||
function handleResourcesRead(params: Record<string, unknown>): unknown {
|
||||
const uri = String(params.uri);
|
||||
const id = uri.replace("notes://", "");
|
||||
const n = NOTES[id];
|
||||
if (!n) throw new Error(`not found: ${uri}`);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: "text/markdown",
|
||||
text: `# ${n.title}\n\n${n.body}\n\ntag: ${n.tag}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function handlePromptsList(): unknown {
|
||||
return { prompts: PROMPTS };
|
||||
}
|
||||
|
||||
function handlePromptsGet(params: Record<string, unknown>): unknown {
|
||||
if (params.name !== "review_note") throw new Error("unknown prompt");
|
||||
const args = (params.arguments as Record<string, unknown> | undefined) ?? {};
|
||||
const id = String(args.note_id ?? "");
|
||||
const body = NOTES[id]?.body ?? "(not found)";
|
||||
return {
|
||||
description: "Review the note and propose concrete improvements.",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Review this note and propose improvements:\n\n${body}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const HANDLERS: Record<string, (params: Record<string, unknown>) => unknown> = {
|
||||
initialize: handleInitialize,
|
||||
"tools/list": handleToolsList,
|
||||
"tools/call": handleToolsCall,
|
||||
"resources/list": handleResourcesList,
|
||||
"resources/read": handleResourcesRead,
|
||||
"prompts/list": handlePromptsList,
|
||||
"prompts/get": handlePromptsGet,
|
||||
};
|
||||
|
||||
function dispatch(msg: JsonRpcRequest): JsonRpcResponse | null {
|
||||
const method = msg.method;
|
||||
if (msg.id === undefined) return null;
|
||||
const id = msg.id;
|
||||
const handler = HANDLERS[method];
|
||||
if (!handler) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32601, message: `Method not found: ${method}` },
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = handler(msg.params ?? {});
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
} catch (err) {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: -32603, message: String(err) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serveStdio(): void {
|
||||
const rl = createInterface({ input: process.stdin, terminal: false });
|
||||
rl.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
let msg: JsonRpcRequest;
|
||||
try {
|
||||
msg = JSON.parse(trimmed) as JsonRpcRequest;
|
||||
} catch (err) {
|
||||
process.stderr.write(`parse error: ${String(err)}\n`);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: { code: -32700, message: "Parse error", data: String(err) },
|
||||
}) + "\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resp = dispatch(msg);
|
||||
if (resp) process.stdout.write(JSON.stringify(resp) + "\n");
|
||||
});
|
||||
}
|
||||
|
||||
function demo(): void {
|
||||
console.log("=".repeat(72));
|
||||
console.log("PHASE 13 LESSON 07 - MCP SERVER DEMO (TypeScript port, no transport)");
|
||||
console.log("=".repeat(72));
|
||||
|
||||
const scenarios: JsonRpcRequest[] = [
|
||||
{ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: PROTOCOL_VERSION } },
|
||||
{ jsonrpc: "2.0", id: 2, method: "tools/list" },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "notes_search", arguments: { query: "MCP" } },
|
||||
},
|
||||
{ jsonrpc: "2.0", id: 4, method: "resources/list" },
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "resources/read",
|
||||
params: { uri: "notes://note-1" },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 6,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "notes_create",
|
||||
arguments: { title: "Session notes", body: "Built it.", tag: "mcp" },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
method: "prompts/get",
|
||||
params: { name: "review_note", arguments: { note_id: "note-1" } },
|
||||
},
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 8,
|
||||
method: "tools/call",
|
||||
params: { name: "no_such_tool", arguments: {} },
|
||||
},
|
||||
];
|
||||
|
||||
for (const msg of scenarios) {
|
||||
console.log("\n>>>", msg.method);
|
||||
const resp = dispatch(msg);
|
||||
console.log(JSON.stringify(resp, null, 2).slice(0, 400));
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
if (process.argv.includes("--demo")) {
|
||||
demo();
|
||||
} else {
|
||||
serveStdio();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,210 @@
|
||||
// Phase 13 Lesson 19 — A2A agent-to-agent protocol, in TypeScript.
|
||||
//
|
||||
// Research agent calls writer agent via A2A:
|
||||
// 1. Research agent fetches writer's Agent Card
|
||||
// 2. Submits a Task with text + file + data parts
|
||||
// 3. Writer transitions working -> input_required -> working -> completed
|
||||
// 4. Research agent receives an Artifact
|
||||
//
|
||||
// Stdlib only; in-process transport stands in for JSON-RPC over HTTP.
|
||||
//
|
||||
// Spec references:
|
||||
// A2A protocol https://a2aproject.github.io/A2A/specification
|
||||
// Agent Card schema https://a2aproject.github.io/A2A/specification/#agent-card
|
||||
//
|
||||
// Run: npx tsx code/main.ts
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
type Capabilities = { streaming: boolean; pushNotifications: boolean };
|
||||
|
||||
type Skill = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
inputModes: string[];
|
||||
outputModes: string[];
|
||||
};
|
||||
|
||||
type AgentCard = {
|
||||
schemaVersion: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
version: string;
|
||||
skills: Skill[];
|
||||
capabilities: Capabilities;
|
||||
};
|
||||
|
||||
const WRITER_AGENT_CARD: AgentCard = {
|
||||
schemaVersion: "1.0",
|
||||
name: "writer-agent",
|
||||
description: "Drafts technical summaries and reports from source material.",
|
||||
url: "https://writer.example.com/a2a",
|
||||
version: "1.0.0",
|
||||
skills: [
|
||||
{
|
||||
id: "draft_report",
|
||||
name: "Draft report",
|
||||
description: "Given source material and a target length, produce a report.",
|
||||
inputModes: ["text", "file", "data"],
|
||||
outputModes: ["text", "artifact"],
|
||||
},
|
||||
],
|
||||
capabilities: { streaming: true, pushNotifications: false },
|
||||
};
|
||||
|
||||
type TextPart = { kind: "text"; payload: { text: string } };
|
||||
type FilePart = {
|
||||
kind: "file";
|
||||
payload: { file: { name: string; mimeType: string; bytes: string } };
|
||||
};
|
||||
type DataPart = { kind: "data"; payload: Record<string, unknown> };
|
||||
type Part = TextPart | FilePart | DataPart;
|
||||
|
||||
type Message = { role: "user" | "agent"; parts: Part[] };
|
||||
|
||||
type Artifact = { name: string; mimeType: string; parts: Part[] };
|
||||
|
||||
type TaskState =
|
||||
| "submitted"
|
||||
| "working"
|
||||
| "input_required"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "canceled";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
state: TaskState;
|
||||
messages: Message[];
|
||||
artifact: Artifact | null;
|
||||
};
|
||||
|
||||
const TASK_STORE = new Map<string, Task>();
|
||||
|
||||
function newTask(): Task {
|
||||
const id = `task_${randomUUID().replace(/-/g, "").slice(0, 10)}`;
|
||||
const task: Task = { id, state: "submitted", messages: [], artifact: null };
|
||||
TASK_STORE.set(id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function findDataPart(message: Message): DataPart | undefined {
|
||||
return message.parts.find((p): p is DataPart => p.kind === "data");
|
||||
}
|
||||
|
||||
function finish(task: Task, length: string): void {
|
||||
const text =
|
||||
`[writer agent] ${length} summary of provided source: ` +
|
||||
`topic identified, key points extracted, conclusion drafted.`;
|
||||
task.artifact = {
|
||||
name: "summary",
|
||||
mimeType: "text/markdown",
|
||||
parts: [{ kind: "text", payload: { text } }],
|
||||
};
|
||||
task.state = "completed";
|
||||
console.log(` WRITER : completed task ${task.id}`);
|
||||
}
|
||||
|
||||
function writerTasksSend(skillId: string, message: Message): Task {
|
||||
const task = newTask();
|
||||
task.state = "working";
|
||||
task.messages.push(message);
|
||||
console.log(` WRITER : started task ${task.id} skill=${skillId}`);
|
||||
|
||||
const data = findDataPart(message);
|
||||
if (!data || !("targetLength" in data.payload)) {
|
||||
task.state = "input_required";
|
||||
task.messages.push({
|
||||
role: "agent",
|
||||
parts: [
|
||||
{
|
||||
kind: "text",
|
||||
payload: { text: "Please specify targetLength as a data part." },
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log(` WRITER : paused input_required`);
|
||||
} else {
|
||||
finish(task, String(data.payload.targetLength));
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function writerTasksReply(taskId: string, message: Message): Task {
|
||||
const task = TASK_STORE.get(taskId);
|
||||
if (!task) throw new Error(`unknown task ${taskId}`);
|
||||
task.messages.push(message);
|
||||
const data = findDataPart(message);
|
||||
if (task.state === "input_required" && data) {
|
||||
task.state = "working";
|
||||
finish(task, String(data.payload.targetLength ?? "short"));
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function researchAgentFlow(): void {
|
||||
console.log("=".repeat(72));
|
||||
console.log("PHASE 13 LESSON 19 - A2A CALL FROM RESEARCH TO WRITER (TypeScript port)");
|
||||
console.log("=".repeat(72));
|
||||
|
||||
console.log("\n--- research agent fetches writer Agent Card ---");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: WRITER_AGENT_CARD.name,
|
||||
url: WRITER_AGENT_CARD.url,
|
||||
skills: WRITER_AGENT_CARD.skills,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const skill = WRITER_AGENT_CARD.skills[0];
|
||||
const skillId = skill.id;
|
||||
console.log(`\n research agent will invoke skill: ${skillId}`);
|
||||
|
||||
const fakePdfBytes = Buffer.from("fake-pdf").toString("base64");
|
||||
const initialMessage: Message = {
|
||||
role: "user",
|
||||
parts: [
|
||||
{ kind: "text", payload: { text: "Summarize the attached paper." } },
|
||||
{
|
||||
kind: "file",
|
||||
payload: {
|
||||
file: { name: "paper.pdf", mimeType: "application/pdf", bytes: fakePdfBytes },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
let task = writerTasksSend(skillId, initialMessage);
|
||||
console.log(` research : task state = ${task.state}`);
|
||||
|
||||
if (task.state === "input_required") {
|
||||
console.log("\n--- research agent supplies the missing data ---");
|
||||
const followup: Message = {
|
||||
role: "user",
|
||||
parts: [{ kind: "data", payload: { targetLength: "3 paragraphs" } }],
|
||||
};
|
||||
task = writerTasksReply(task.id, followup);
|
||||
console.log(` research : task state = ${task.state}`);
|
||||
}
|
||||
|
||||
console.log("\n--- research agent reads artifact ---");
|
||||
if (task.artifact) {
|
||||
const firstPart = task.artifact.parts[0];
|
||||
console.log(` name : ${task.artifact.name}`);
|
||||
console.log(` mimeType : ${task.artifact.mimeType}`);
|
||||
if (firstPart.kind === "text") {
|
||||
console.log(` content : ${firstPart.payload.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n--- lifecycle observation ---");
|
||||
console.log(` final state : ${task.state}`);
|
||||
console.log(` messages : ${task.messages.length}`);
|
||||
}
|
||||
|
||||
researchAgentFlow();
|
||||
@@ -0,0 +1,210 @@
|
||||
// Phase 14 Lesson 01 — toy ReAct agent loop, in TypeScript.
|
||||
//
|
||||
// Mirrors code/main.py: message buffer, tool registry, stop condition,
|
||||
// turn budget, observation formatter. The model is a scripted ToyLLM so the
|
||||
// loop runs offline and deterministic; swap for a real provider client and
|
||||
// the control flow is identical.
|
||||
//
|
||||
// References:
|
||||
// ReAct paper https://arxiv.org/abs/2210.03629
|
||||
// Anthropic agents https://www.anthropic.com/engineering/building-effective-agents
|
||||
//
|
||||
// Run: npx tsx code/main.ts
|
||||
|
||||
type ToolFn = (args: Record<string, string>) => string;
|
||||
|
||||
type ToolCall = {
|
||||
name: string;
|
||||
args: Record<string, string>;
|
||||
};
|
||||
|
||||
type Turn = {
|
||||
kind: "user" | "thought" | "action" | "final";
|
||||
content: string;
|
||||
toolCall?: ToolCall;
|
||||
observation?: string;
|
||||
};
|
||||
|
||||
class ToolRegistry {
|
||||
private tools = new Map<string, ToolFn>();
|
||||
|
||||
register(name: string, fn: ToolFn): void {
|
||||
this.tools.set(name, fn);
|
||||
}
|
||||
|
||||
names(): string[] {
|
||||
return [...this.tools.keys()].sort();
|
||||
}
|
||||
|
||||
dispatch(call: ToolCall): string {
|
||||
const fn = this.tools.get(call.name);
|
||||
if (!fn) return `error: unknown tool ${JSON.stringify(call.name)}`;
|
||||
try {
|
||||
return fn(call.args);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
return `error: ${e.name}: ${e.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculator(args: Record<string, string>): string {
|
||||
const expr = args.expr;
|
||||
if (typeof expr !== "string") return "error: missing expr";
|
||||
if (!/^[0-9+\-*/(). ]+$/.test(expr)) {
|
||||
return "error: illegal character in expr";
|
||||
}
|
||||
try {
|
||||
const fn = new Function(`"use strict"; return (${expr});`);
|
||||
const value = fn();
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return `error: non-finite result for ${expr}`;
|
||||
}
|
||||
return String(value);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
return `error: ${e.name}: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
class KVStore {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
get = (args: Record<string, string>): string => {
|
||||
const key = args.key;
|
||||
if (!this.store.has(key)) return `missing:${key}`;
|
||||
return this.store.get(key) as string;
|
||||
};
|
||||
|
||||
set = (args: Record<string, string>): string => {
|
||||
this.store.set(args.key, args.value);
|
||||
return `stored ${args.key}`;
|
||||
};
|
||||
}
|
||||
|
||||
type ScriptEntry =
|
||||
| { kind: "action"; thought: string; action: string; args: Record<string, string> }
|
||||
| { kind: "finish"; content: string };
|
||||
|
||||
// Scripted ReAct policy. Returns one assistant turn per call.
|
||||
// Replace with a provider client and the loop is identical.
|
||||
class ToyLLM {
|
||||
private cursor = 0;
|
||||
constructor(private script: ScriptEntry[]) {}
|
||||
|
||||
respond(_history: Turn[]): ScriptEntry {
|
||||
if (this.cursor >= this.script.length) {
|
||||
return { kind: "finish", content: "no more actions" };
|
||||
}
|
||||
return this.script[this.cursor++];
|
||||
}
|
||||
}
|
||||
|
||||
class AgentLoop {
|
||||
history: Turn[] = [];
|
||||
|
||||
constructor(
|
||||
private llm: ToyLLM,
|
||||
private tools: ToolRegistry,
|
||||
private maxTurns = 12,
|
||||
) {}
|
||||
|
||||
run(userMessage: string): string {
|
||||
this.history.push({ kind: "user", content: userMessage });
|
||||
for (let step = 0; step < this.maxTurns; step++) {
|
||||
const reply = this.llm.respond(this.history);
|
||||
if (reply.kind === "finish") {
|
||||
this.history.push({ kind: "final", content: reply.content });
|
||||
return reply.content;
|
||||
}
|
||||
this.history.push({ kind: "thought", content: reply.thought });
|
||||
const call: ToolCall = { name: reply.action, args: reply.args };
|
||||
const observation = this.tools.dispatch(call);
|
||||
this.history.push({
|
||||
kind: "action",
|
||||
content: call.name,
|
||||
toolCall: call,
|
||||
observation,
|
||||
});
|
||||
}
|
||||
this.history.push({ kind: "final", content: "budget exhausted" });
|
||||
return "budget exhausted";
|
||||
}
|
||||
|
||||
toolNames(): string[] {
|
||||
return this.tools.names();
|
||||
}
|
||||
}
|
||||
|
||||
function prettyTrace(history: Turn[]): void {
|
||||
history.forEach((turn, i) => {
|
||||
const tag = `[${String(i).padStart(2, "0")} ${turn.kind.padStart(7)}]`;
|
||||
if (turn.kind === "user" || turn.kind === "thought" || turn.kind === "final") {
|
||||
console.log(`${tag} ${turn.content}`);
|
||||
} else if (turn.kind === "action" && turn.toolCall) {
|
||||
const argText = JSON.stringify(turn.toolCall.args);
|
||||
console.log(`${tag} ${turn.toolCall.name}(${argText}) -> ${turn.observation}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildDemoAgent(): AgentLoop {
|
||||
const tools = new ToolRegistry();
|
||||
tools.register("calculator", calculator);
|
||||
const kv = new KVStore();
|
||||
tools.register("kv_get", kv.get);
|
||||
tools.register("kv_set", kv.set);
|
||||
|
||||
const script: ScriptEntry[] = [
|
||||
{
|
||||
kind: "action",
|
||||
thought: "store the base price",
|
||||
action: "kv_set",
|
||||
args: { key: "base", value: "120" },
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
thought: "compute 15% tax",
|
||||
action: "calculator",
|
||||
args: { expr: "120 * 0.15" },
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
thought: "store the tax",
|
||||
action: "kv_set",
|
||||
args: { key: "tax", value: "18.0" },
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
thought: "compute total",
|
||||
action: "calculator",
|
||||
args: { expr: "120 + 18.0" },
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
thought: "confirm stored values",
|
||||
action: "kv_get",
|
||||
args: { key: "base" },
|
||||
},
|
||||
{ kind: "finish", content: "the total including 15% tax is 138.0" },
|
||||
];
|
||||
return new AgentLoop(new ToyLLM(script), tools, 10);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log("=".repeat(70));
|
||||
console.log("TOY REACT LOOP — Phase 14, Lesson 01 (TypeScript port)");
|
||||
console.log("=".repeat(70));
|
||||
|
||||
const agent = buildDemoAgent();
|
||||
const final = agent.run("What is 120 plus 15% tax, stored in kv?");
|
||||
console.log();
|
||||
prettyTrace(agent.history);
|
||||
console.log();
|
||||
console.log(`final answer: ${final}`);
|
||||
const actions = agent.history.filter((t) => t.kind === "action").length;
|
||||
console.log(`turns used: ${actions}`);
|
||||
console.log(`tools used: ${JSON.stringify(agent.toolNames())}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,266 @@
|
||||
// Phase 14 Lesson 06 — tool use and function calling, in TypeScript.
|
||||
//
|
||||
// Stdlib tool registry with JSON Schema subset validation and parallel dispatch.
|
||||
// Subset: required fields, string/integer/number/boolean/array/object,
|
||||
// enum, minimum/maximum. Every validation failure becomes a structured
|
||||
// observation so an agent can retry.
|
||||
//
|
||||
// References:
|
||||
// OpenAI function-calling https://platform.openai.com/docs/guides/function-calling
|
||||
// Anthropic tool-use https://docs.anthropic.com/en/docs/build-with-claude/tool-use
|
||||
// JSON Schema 2020-12 https://json-schema.org/draft/2020-12
|
||||
//
|
||||
// Run: npx tsx code/main.ts
|
||||
|
||||
type Primitive = "integer" | "number" | "boolean" | "string" | "array" | "object";
|
||||
|
||||
type PropSchema = {
|
||||
type: Primitive;
|
||||
enum?: unknown[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
};
|
||||
|
||||
type ToolInputSchema = {
|
||||
type: "object";
|
||||
properties: Record<string, PropSchema>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
|
||||
type ToolDef = {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: ToolInputSchema;
|
||||
executor: (args: ToolArgs) => string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type ToolCall = {
|
||||
toolUseId: string;
|
||||
name: string;
|
||||
args: ToolArgs;
|
||||
};
|
||||
|
||||
type ToolResult = {
|
||||
toolUseId: string;
|
||||
ok: boolean;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function describeType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (typeof value === "number" && Number.isInteger(value)) return "integer";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function coerce(value: unknown, schema: PropSchema): { value: unknown; error: string | null } {
|
||||
const t = schema.type;
|
||||
if (t === "integer") {
|
||||
if (typeof value === "number" && Number.isInteger(value)) return { value, error: null };
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isInteger(parsed)) return { value: parsed, error: null };
|
||||
return { value, error: `cannot coerce string ${JSON.stringify(value)} to integer` };
|
||||
}
|
||||
return { value, error: `expected integer, got ${describeType(value)}` };
|
||||
}
|
||||
if (t === "number") {
|
||||
if (typeof value === "number") return { value, error: null };
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return { value: parsed, error: null };
|
||||
return { value, error: `cannot coerce string ${JSON.stringify(value)} to number` };
|
||||
}
|
||||
return { value, error: `expected number, got ${describeType(value)}` };
|
||||
}
|
||||
if (t === "boolean") {
|
||||
if (typeof value === "boolean") return { value, error: null };
|
||||
return { value, error: `expected boolean, got ${describeType(value)}` };
|
||||
}
|
||||
if (t === "string") {
|
||||
if (typeof value === "string") return { value, error: null };
|
||||
return { value, error: `expected string, got ${describeType(value)}` };
|
||||
}
|
||||
if (t === "array") {
|
||||
if (Array.isArray(value)) return { value, error: null };
|
||||
return { value, error: `expected array, got ${describeType(value)}` };
|
||||
}
|
||||
if (t === "object") {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return { value, error: null };
|
||||
}
|
||||
return { value, error: `expected object, got ${describeType(value)}` };
|
||||
}
|
||||
return { value, error: null };
|
||||
}
|
||||
|
||||
function validate(args: ToolArgs, schema: ToolInputSchema): { out: ToolArgs; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const props = schema.properties;
|
||||
const required = schema.required ?? [];
|
||||
const out: ToolArgs = {};
|
||||
|
||||
for (const name of required) {
|
||||
if (!(name in args)) errors.push(`missing required: ${name}`);
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(args)) {
|
||||
const prop = props[name];
|
||||
if (!prop) {
|
||||
errors.push(`unknown field: ${name}`);
|
||||
continue;
|
||||
}
|
||||
const { value: coerced, error } = coerce(value, prop);
|
||||
if (error) {
|
||||
errors.push(`${name}: ${error}`);
|
||||
continue;
|
||||
}
|
||||
if (prop.enum && !prop.enum.includes(coerced as never)) {
|
||||
errors.push(`${name}: ${JSON.stringify(coerced)} not in ${JSON.stringify(prop.enum)}`);
|
||||
continue;
|
||||
}
|
||||
if (prop.type === "number" || prop.type === "integer") {
|
||||
const numVal = coerced as number;
|
||||
if (prop.minimum !== undefined && numVal < prop.minimum) {
|
||||
errors.push(`${name}: ${numVal} < minimum ${prop.minimum}`);
|
||||
continue;
|
||||
}
|
||||
if (prop.maximum !== undefined && numVal > prop.maximum) {
|
||||
errors.push(`${name}: ${numVal} > maximum ${prop.maximum}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out[name] = coerced;
|
||||
}
|
||||
|
||||
return { out, errors };
|
||||
}
|
||||
|
||||
class ToolRegistry {
|
||||
private tools = new Map<string, ToolDef>();
|
||||
|
||||
register(tool: ToolDef): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
catalog(): Array<Pick<ToolDef, "name" | "description" | "inputSchema">> {
|
||||
return [...this.tools.values()].map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
}));
|
||||
}
|
||||
|
||||
dispatch(call: ToolCall): ToolResult {
|
||||
const tool = this.tools.get(call.name);
|
||||
if (!tool) {
|
||||
return { toolUseId: call.toolUseId, ok: false, content: `error: unknown tool ${JSON.stringify(call.name)}` };
|
||||
}
|
||||
const { out, errors } = validate(call.args, tool.inputSchema);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
toolUseId: call.toolUseId,
|
||||
ok: false,
|
||||
content: `validation error: ${errors.join("; ")}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return { toolUseId: call.toolUseId, ok: true, content: tool.executor(out) };
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
return {
|
||||
toolUseId: call.toolUseId,
|
||||
ok: false,
|
||||
content: `execution error: ${e.name}: ${e.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
dispatchMany(calls: ToolCall[]): ToolResult[] {
|
||||
return calls.map((c) => this.dispatch(c));
|
||||
}
|
||||
}
|
||||
|
||||
function add(args: ToolArgs): string {
|
||||
const a = args.a as number;
|
||||
const b = args.b as number;
|
||||
return String(a + b);
|
||||
}
|
||||
|
||||
function multiply(args: ToolArgs): string {
|
||||
const a = args.a as number;
|
||||
const b = args.b as number;
|
||||
return String(a * b);
|
||||
}
|
||||
|
||||
function classify(args: ToolArgs): string {
|
||||
return `classified as ${args.status as string}`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log("=".repeat(70));
|
||||
console.log("TOOL USE and FUNCTION CALLING — Phase 14, Lesson 06 (TypeScript port)");
|
||||
console.log("=".repeat(70));
|
||||
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "add",
|
||||
description: "Add two integers a and b. Use for any integer addition.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { a: { type: "integer" }, b: { type: "integer" } },
|
||||
required: ["a", "b"],
|
||||
},
|
||||
executor: add,
|
||||
});
|
||||
reg.register({
|
||||
name: "multiply",
|
||||
description: "Multiply two integers a and b. Prefer multiplication over looped addition.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { a: { type: "integer" }, b: { type: "integer" } },
|
||||
required: ["a", "b"],
|
||||
},
|
||||
executor: multiply,
|
||||
});
|
||||
reg.register({
|
||||
name: "classify",
|
||||
description: "Classify a status as one of the allowed labels.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["open", "closed", "pending"] },
|
||||
},
|
||||
required: ["status"],
|
||||
},
|
||||
executor: classify,
|
||||
});
|
||||
|
||||
console.log("\ncatalog (as presented to the model)");
|
||||
for (const entry of reg.catalog()) {
|
||||
console.log(` - ${entry.name}: ${entry.description}`);
|
||||
}
|
||||
|
||||
const calls: ToolCall[] = [
|
||||
{ toolUseId: "u01", name: "add", args: { a: 2, b: 3 } },
|
||||
{ toolUseId: "u02", name: "multiply", args: { a: "4", b: 5 } },
|
||||
{ toolUseId: "u03", name: "classify", args: { status: "in_progress" } },
|
||||
{ toolUseId: "u04", name: "classify", args: { status: "open" } },
|
||||
{ toolUseId: "u05", name: "subtract", args: { a: 1, b: 2 } },
|
||||
];
|
||||
|
||||
console.log("\nparallel dispatch (5 calls in one turn)");
|
||||
for (const result of reg.dispatchMany(calls)) {
|
||||
const tag = result.ok ? "OK " : "ERR";
|
||||
console.log(` ${result.toolUseId} ${tag}: ${result.content}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log("observation shape: every validation failure is a structured error");
|
||||
console.log("string the agent can read and retry against. never raise to the loop.");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,244 @@
|
||||
// Phase 14 Lesson 13 — LangGraph-shaped stateful graph, in TypeScript.
|
||||
//
|
||||
// Mirrors code/main.py: State is a plain object, nodes return Update objects,
|
||||
// the runtime serializes state after every node so resume picks up exactly
|
||||
// where it left off. Human gate pauses; an external approval lets resume()
|
||||
// continue from the next node.
|
||||
//
|
||||
// References:
|
||||
// LangGraph (TS) https://langchain-ai.github.io/langgraphjs/
|
||||
// StateGraph reference https://langchain-ai.github.io/langgraphjs/reference/classes/langgraph.StateGraph.html
|
||||
//
|
||||
// Run: npx tsx code/main.ts
|
||||
|
||||
type State = Record<string, unknown>;
|
||||
type Update = Record<string, unknown>;
|
||||
type NodeFn = (state: State) => Update;
|
||||
type Router = (state: State) => string;
|
||||
type Predicate = (state: State) => boolean;
|
||||
|
||||
const END = "__end__";
|
||||
|
||||
type Edge = {
|
||||
src: string;
|
||||
dst: string;
|
||||
predicate: Predicate | null;
|
||||
};
|
||||
|
||||
class StateGraph {
|
||||
nodes = new Map<string, NodeFn>();
|
||||
edges = new Map<string, Edge[]>();
|
||||
entry: string | null = null;
|
||||
|
||||
addNode(name: string, fn: NodeFn): void {
|
||||
this.nodes.set(name, fn);
|
||||
}
|
||||
|
||||
setEntry(name: string): void {
|
||||
this.entry = name;
|
||||
}
|
||||
|
||||
addEdge(src: string, dst: string): void {
|
||||
const list = this.edges.get(src) ?? [];
|
||||
list.push({ src, dst, predicate: null });
|
||||
this.edges.set(src, list);
|
||||
}
|
||||
|
||||
addConditionalEdges(
|
||||
src: string,
|
||||
router: Router,
|
||||
targets: Record<string, string>,
|
||||
): void {
|
||||
for (const [value, dst] of Object.entries(targets)) {
|
||||
const predicate: Predicate = (state) => router(state) === value;
|
||||
const list = this.edges.get(src) ?? [];
|
||||
list.push({ src, dst, predicate });
|
||||
this.edges.set(src, list);
|
||||
}
|
||||
}
|
||||
|
||||
next(current: string, state: State): string | null {
|
||||
for (const edge of this.edges.get(current) ?? []) {
|
||||
if (edge.predicate === null || edge.predicate(state)) return edge.dst;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryCheckpointer {
|
||||
private store = new Map<string, Array<[string, State]>>();
|
||||
|
||||
save(sessionId: string, stepName: string, state: State): void {
|
||||
const list = this.store.get(sessionId) ?? [];
|
||||
list.push([stepName, structuredClone(state)]);
|
||||
this.store.set(sessionId, list);
|
||||
}
|
||||
|
||||
loadLatest(sessionId: string): [string, State] | null {
|
||||
const list = this.store.get(sessionId);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
}
|
||||
|
||||
history(sessionId: string): Array<[string, State]> {
|
||||
return [...(this.store.get(sessionId) ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
class PausedAtNode extends Error {
|
||||
constructor(public node: string, public state: State) {
|
||||
super(node);
|
||||
this.name = "PausedAtNode";
|
||||
}
|
||||
}
|
||||
|
||||
type RunOptions = {
|
||||
sessionId: string;
|
||||
initialState: State;
|
||||
resumeFrom?: string;
|
||||
stateOverride?: State;
|
||||
};
|
||||
|
||||
class Runner {
|
||||
constructor(public graph: StateGraph, public checkpointer: InMemoryCheckpointer) {}
|
||||
|
||||
run(opts: RunOptions): State {
|
||||
const { sessionId, initialState, resumeFrom, stateOverride } = opts;
|
||||
let state: State = structuredClone(stateOverride ?? initialState);
|
||||
let current = resumeFrom ?? this.graph.entry;
|
||||
if (!current) throw new Error("no entry node set");
|
||||
|
||||
while (current && current !== END) {
|
||||
const fn = this.graph.nodes.get(current);
|
||||
if (!fn) throw new Error(`unknown node ${JSON.stringify(current)}`);
|
||||
const update = fn(state) ?? {};
|
||||
state = { ...state, ...update };
|
||||
this.checkpointer.save(sessionId, current, state);
|
||||
if (state._pause_reason) {
|
||||
const reason = state._pause_reason;
|
||||
delete state._pause_reason;
|
||||
void reason;
|
||||
throw new PausedAtNode(current, state);
|
||||
}
|
||||
const nxt = this.graph.next(current, state);
|
||||
current = nxt;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function classify(state: State): Update {
|
||||
const text = String(state.input).toLowerCase();
|
||||
let route: string;
|
||||
if (text.includes("refund") || text.includes("money back")) route = "refund";
|
||||
else if (text.includes("crash") || text.includes("bug") || text.includes("error")) route = "bug";
|
||||
else if (text.includes("pricing") || text.includes("quote")) route = "sales";
|
||||
else route = "sales";
|
||||
return { route, step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function refund(state: State): Update {
|
||||
return { ticket: `REF-${String(state.input ?? "").slice(0, 12)}`, step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function bug(state: State): Update {
|
||||
return { ticket: `BUG-${String(state.input ?? "").slice(0, 12)}`, step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function sales(state: State): Update {
|
||||
return { ticket: `SAL-${String(state.input ?? "").slice(0, 12)}`, step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function humanGate(state: State): Update {
|
||||
if (!state.human_approval) {
|
||||
return { _pause_reason: "awaiting human approval", step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
return { step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function send(state: State): Update {
|
||||
return { output: `sent ${state.ticket as string | undefined}`, step: (state.step as number ?? 0) + 1 };
|
||||
}
|
||||
|
||||
function buildGraph(): StateGraph {
|
||||
const g = new StateGraph();
|
||||
g.addNode("classify", classify);
|
||||
g.addNode("refund", refund);
|
||||
g.addNode("bug", bug);
|
||||
g.addNode("sales", sales);
|
||||
g.addNode("human_gate", humanGate);
|
||||
g.addNode("send", send);
|
||||
g.setEntry("classify");
|
||||
|
||||
g.addConditionalEdges(
|
||||
"classify",
|
||||
(s) => String(s.route),
|
||||
{ refund: "refund", bug: "bug", sales: "sales" },
|
||||
);
|
||||
g.addEdge("refund", "human_gate");
|
||||
g.addEdge("bug", "human_gate");
|
||||
g.addEdge("sales", "human_gate");
|
||||
g.addEdge("human_gate", "send");
|
||||
g.addEdge("send", END);
|
||||
return g;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log("=".repeat(70));
|
||||
console.log("LANGGRAPH STATE MACHINE — Phase 14, Lesson 13 (TypeScript port)");
|
||||
console.log("=".repeat(70));
|
||||
|
||||
const graph = buildGraph();
|
||||
const ckpt = new InMemoryCheckpointer();
|
||||
const runner = new Runner(graph, ckpt);
|
||||
|
||||
const session = "s001";
|
||||
const initial: State = {
|
||||
input: "the CLI crashes on ctrl-c, please fix",
|
||||
step: 0,
|
||||
human_approval: false,
|
||||
};
|
||||
|
||||
console.log("\nfirst run (will pause at human_gate)");
|
||||
try {
|
||||
const final = runner.run({ sessionId: session, initialState: initial });
|
||||
console.log(` final: ${JSON.stringify(final)}`);
|
||||
} catch (err) {
|
||||
if (err instanceof PausedAtNode) {
|
||||
console.log(` PAUSED at ${err.node}`);
|
||||
console.log(` state at pause: ${JSON.stringify(err.state)}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ncheckpoint history");
|
||||
for (const [node, snap] of ckpt.history(session)) {
|
||||
console.log(
|
||||
` ${node} route=${snap.route as string | undefined} ` +
|
||||
`ticket=${snap.ticket as string | undefined} step=${snap.step as number | undefined}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nhuman approves; resume from next node after human_gate");
|
||||
const latest = ckpt.loadLatest(session);
|
||||
if (!latest) throw new Error("no checkpoint");
|
||||
const [lastNode, lastState] = latest;
|
||||
const approved: State = { ...lastState, human_approval: true };
|
||||
delete approved._pause_reason;
|
||||
ckpt.save(session, `${lastNode}_reviewed`, approved);
|
||||
|
||||
const final = runner.run({
|
||||
sessionId: session,
|
||||
initialState: initial,
|
||||
resumeFrom: "send",
|
||||
stateOverride: approved,
|
||||
});
|
||||
console.log(` final: ${JSON.stringify(final)}`);
|
||||
|
||||
console.log();
|
||||
console.log("property: state serializes after every node; resume is exact.");
|
||||
console.log("no fresh re-runs after step 38 fails; pick up at step 39.");
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user