03 — TypeScript Package Spec (source of truth)¶
Package: framework-agents — import only from framework-agents (deep imports are unsupported).
Agent¶
new Agent({
name?: string; // default "agent"
systemPrompt?: string;
model: ModelProvider; // required
tools?: Tool[]; // default []
memory?: Memory;
maxSteps?: number; // default 6
events?: AgentEvents;
})
await agent.run(input: string, signal?: AbortSignal): Promise<AgentResult>
AgentResult = { text, messages, toolCalls: { id, name, args, output }[], usage: { inputTokens, outputTokens, reasoningTokens? } }
ChatMessage (content-block standard)¶
content: string | ContentBlock[] // strings for simple cases, blocks for rich payloads
type ContentBlock =
| { type: "text"; text: string }
| { type: "reasoning"; reasoning: string }
| { type: "image"; url?; data?; mimeType?; fileId? }
| { type: "audio" | "file"; ... }
| { type: "tool_call"; id: string; name: string; args: unknown }
| { type: "non_standard"; value: unknown }; // provider escape hatch
system(content) / user(content, { name? }) / assistant(content) / toolResult(toolCallId, toolName, content)
textOf(content) / hasImages(content) / imagesOf(content)
serializeMessages(msgs) / deserializeMessages(raw) // validated persistence round-trip
Images on a model without vision support fail fast with AgentError("MODEL_CAPABILITY") before any network call (see src/models/capabilities.ts).
Tool¶
defineTool({ name, description, schema: z.ZodType, execute: (args) => Promise<output> })
new ToolRegistry().register(tool).call(name, rawArgs) // parses with zod, throws on bad args
Memory¶
interface Memory { load(): Promise<ChatMessage[]>; save(msgs): Promise<void>; clear(): Promise<void> }
new InMemoryMemory(maxMessages = 50)
ModelProvider¶
interface ModelProvider {
readonly id: string;
complete(opts: { messages: ChatMessage[]; tools: { name; description }[] }): Promise<{
text: string; toolCalls: { name; args }[]; usage: TokenUsage;
}>;
}
new MockModelProvider() // deterministic, for tests/examples
Runtime¶
State (three lifetimes)¶
new Agent({ state: { theme: "dark" }, initialMessages: [...], conversationManager, sessionManager })
agent.state.get/set/delete // validated JSON KV, never sent to the model
agent.messages // history from the last run
await agent.run("...", { invocationState: { user_id: "u1" } }) // per-run, shared by ref, returned on result
// tools receive (args, { agentName, state, invocationState })
Conversation managers¶
new NullConversationManager() // keep everything
new SlidingWindowConversationManager({ windowSize: 40, pinFirst: 1, perTurn: true })
new SummarizingConversationManager({ summaryRatio: 0.3, preserveRecent: 10, summarizer? | model? })
Sessions + snapshots¶
new SessionManager({ sessionId, storage: new LocalFileStorage("./sessions") }) // or InMemoryStorage
agent.takeSnapshot({ preset: "session", include?, exclude?, appData? })
await agent.loadSnapshot(snapshot) // restores present fields only
await manager.saveSnapshot(data) / listSnapshotIds() / restoreSnapshot(id) / deleteSession()
Multi-agent¶
await runOrchestrated({ strategy: "sequential" | "parallel" | "router", agents }, input, { invocationState })
await runWorkflow(nodes, { invocationState }) // nodes get (ctx, invocationState)
new Swarm({ agents, entryPoint?, maxHandoffs?, maxIterations?, executionTimeoutMs?, nodeTimeoutMs?, loopDetectionWindow?, minUniqueAgents? })
await swarm.run(task, { sharedState }) // sharedState = cross-agent invocationState
for await (const e of swarm.stream(task)) {} // node_start/done, handoff, agent_event, result
A2A interop¶
const client = new A2AClient("http://remote:9000");
await client.getCard(); await client.send("hi", "ctx-1");
a2aAsTool(client) // remote agent as a framework Tool
const server = new A2AServer({ card, agentFactory: (ctx) => new Agent({...}) });
await server.start(port); // card + message/send, per-context isolation
AgentEvents¶
{ onStep?, onToolCall?, onToolResult?, onDone?, onToken?, onThinking? }
consoleEvents(prefix?) // dev logger (streams tokens/thinking to stdout)
agent.stream() (event streaming)¶
for await (const e of agent.stream("...")) // step | token | thinking | tool_call | tool_result | done
import { collectStream } from "framework-agents"; // { events, result }
Structured output (responseFormat)¶
new Agent({ model, responseFormat: toolStrategy(ContactSchema) }) // any tool-calling model
new Agent({ model, responseFormat: providerStrategy(ContactSchema) }) // native response_format
// result.structuredResponse — validated; tool strategy self-repairs via
// validation tool messages; provider strategy retries bad JSON in-loop.
Middleware¶
new Agent({ model, middleware: [modelCallLimit(10), toolRetry({ attempts: 3 })] })
// hooks: beforeModel / wrapModel / afterModel / beforeTool / aroundTool / afterTool
// order: before_* in order, after_* reversed, wrap_* nested (first = outermost)
// middleware may contribute tools (merged into the agent's list)
Prebuilt: toolRetryMiddleware (backoff+jitter/filter/scoping/onFailure), modelRetryMiddleware,
modelFallbackMiddleware(...providers), modelCallLimit, toolCallLimitMiddleware,
humanInTheLoopMiddleware({ interruptOn, decide? }) (approve/edit/reject/suspend),
summarizationMiddleware({ model, triggerTokens, keepMessages }),
piiMiddleware(type, { strategy: redact|mask|hash|block, detector? }),
contextEditingMiddleware({ edits: [new ClearToolUsesEdit(...)] }),
todoListMiddleware() (provides write_todos), llmToolSelectorMiddleware({ model }),
toolEmulatorMiddleware({ model }), filesystemMiddleware({ root }) (ls/read/write/edit),
createSubAgentMiddleware({ model, subagents }) (provides task).
stampRetryable(err, bool) / getRetryable(err) control retry verdicts.
Checkpointed HITL resume (local-first)¶
// suspend: decide returns { suspend: true } (or omit decide) with checkpointer+threadId
await agent.run("ship it", { checkpointer: new FileCheckpointer("./runs"), threadId: "rel-1" });
// → throws HitlInterrupt { interruptId, tool, args }; checkpoint persisted locally
await agent.resume("rel-1", { [hitlKey("deploy", { env: "prod" })]: "approve" }, { checkpointer });
// withFileLock(path, fn) — local exclusive locks (stale reclaim); no distributed locks by design
Skills routing¶
matchSkills(registry, input) — local TF-IDF cosine over name+description, $name mentions always win. No embedding APIs; scoreSkills is the seam for a pluggable embedder later.
Models¶
initChatModel("together:Qwen/Qwen3.5-9B") // bare id defaults to together
initChatModel("openai:gpt-4o-mini") // OPENAI_API_KEY
initChatModel("anthropic:claude-sonnet-4-6", { thinkingBudget: 5000 }) // native API, thinking blocks
initChatModel("ollama:qwen2.5") // local-first, no key (OLLAMA_BASE_URL)
initChatModel("custom:local", { baseUrl, apiKey })
auto responseFormat falls back to the tool strategy. Capability research (vision/structured/streaming per model) lives in src/models/capabilities.ts.
Skills discovery, MCP, semantic store¶
await discoverSkills(["./skills"]) // SKILL.md scan
matchSkills(registry, input) // TF-IDF routing, $mention wins
await connectMcpHttp(url) / connectMcpStdio(cmd) // listTools/callTool
await mcpToolsToFramework(client) // MCP tools -> framework Tools
await store.add(quads) // RDFS entailment to fixpoint
await store.why(quad) // { premises, rule } proof
Quad identity includes the named graph (match(s, p, o, g) filters it), so
the same triple can live in several graphs.
Semantic scale (P2-sem)¶
// Remote SPARQL (SELECT always; UPDATE is opt-in at the tool level)
const remote = new RemoteSparqlAdapter({ endpoint, auth, prefixes });
await remote.select("SELECT ?x WHERE { ... }"); // SPARQL 1.1 protocol, results+json
await remote.update("INSERT DATA { ... }"); // application/sparql-update
// Entity linking: mention -> canonical IRI via labels + owl:sameAs groups
const linker = new EntityLinker(store);
await linker.link("Ada", k); // [{ iri, label, score, sameAsGroup }]
// Autonomous-by-default mapping proposals (provenance mandatory):
const proposals = new MappingProposals({ store }); // commits on propose
const review = new MappingProposals({ autonomy: false }); // queues for decide()
await review.decide(store, id, "approved"); // human promotes to skos:altLabel
// JSON-LD I/O (named graphs round-trip as {"@id": g, "@graph": [...]})
quadsToJsonLd(quads); jsonldToQuads(doc);
// Ontology evolution: pre-battery -> rebuild -> post-battery; old store intact
const { report, store: v4 } = await migrateTBox(v3, {
from: "v3", to: "v4",
renames: [{ from: "ex:worksAt", to: "ex:worksFor" }],
removed: [{ subject: "...", predicate: "...", object: "..." }],
added: [/* axioms with source */],
deprecations: [{ term: "ex:worksAt", replacement: "ex:worksFor", reason: "..." }],
}, { battery: [{ id: "persons-exist", ask: "SELECT ?s WHERE { ?s rdf:type ex:Person . }" }] });
// v4 TBox axioms land in the named graph report.tboxGraph (default urn:framework:ontology:<to>)
// Self-review: the agent audits its own ontology
await reviewOntology(store); // { classes, properties, undeclared, unused,
// unlabeled, missingDomain, missingRange }
const proposals = await proposeShapes(store, { minInstances: 2, coverage: 0.5 });
const { adopted, rejected } = await adoptShapes(store, proposals);
// proposals are INDUCED from data; adoption validates the current closure
// first (shapes the data already violates are rejected 1:1, not registered).
// Tools: reviewOntologyTool(store), proposeShapesTool(store, opts).
Guarantees¶
- Unknown tool →
role: toolmessageUnknown tool: <name>, loop continues. - Bad tool args → zod throws before
execute(viaToolRegistry.call). signal.abort()/timeoutMs→AbortError/AgentError("TIMEOUT").memory.saveis called once perrun(excluding system message).