# agentic

> An opinionated agentic loop library for TypeScript. Tools are first-class citizens — the same `Tool` definition can be invoked directly by the LLM or run inside a sandboxed QuickJS environment via the optional `sandboxTool` factory. Type checking, parallel execution via Promise.all, and human-in-the-loop input via Temporal-style replay.

## Key Concepts

- One `Tool` type for everything the LLM can call. `Loop` accepts a flat `tools: Tool[]` list.
- Two built-in factories return `Tool` instances:
  - `sandboxTool({ tools })` — `id: "run_script"`. Executes TypeScript code in QuickJS with the given tools exposed as global async functions.
  - `describeTool({ tools })` — `id: "describe_tools"`. Returns TypeScript declarations for the given tools so the LLM can discover their signatures.
- You can use just direct LLM tools, just the sandbox + describe pair, or any mix.
- Tools request human input by **throwing `AskRequired`** from `handle`. The loop catches it, marks the `tool_call` output `awaiting_ask`, pauses, and on `loop.resolveAsk(answer)` re-invokes `handle` with `ask` populated. Same mechanism for direct and sandbox-internal tools.
- A single `tool_call` PromptOutput envelope wraps every LLM tool invocation: `{ type, toolCallId, name, args, result, status, pendingAsk?, askOutcome?, toolMessage? }`. Tools that want a custom LLM-facing message implement `formatToolMessage(result)`.
- `lastResult` is passed into `handle` so tools can carry state across calls (e.g., the sandbox tool maintains `globals` and the replay `journal` this way).
- `ask?` on `ToolOptions` is `{ type: "resolved", answer } | { type: "rejected" }` — set by the loop when resuming after a paused call.
- `Math.random` and `Date` are deterministic across replays (seeded PRNG, frozen timestamp captured in the journal).
- A `context` object is passed from `LoopOptions.context` to all tool handlers.
- `loop.prompt` returns the current Prompt at any time for live persistence (Prompt contains all config: model, systemPrompt, temperature, maxIterations).
- `loop.stop()` aborts execution; external `AbortSignal` support via `signal` option.
- `maxTokens` on a Prompt caps total token usage across all iterations; the loop finishes with `token_budget_exceeded` status when the budget is reached.
- `beforeTurn` hooks modify history, model, or system prompt between iterations; `transformMessage` reshapes per-prompt message conversion.
- Custom inputs (`CustomInput`) store arbitrary domain data alongside LLM-ready messages; custom outputs (`PromptOutputCustom`) let tools emit rich data via `emit()`.
- The default `buildSystemPrompt(tools)` is generic — it lists tool ids and descriptions but assumes nothing about how they work. The opt-in `sandboxAgentPrompt` string adds sandbox-specific guidance (small scripts, `global` for state).
- Console logging via `AGENTIC_LOG=debug|info|warn|error` env var or `logLevel` on `LoopOptions`; default is off.
- OpenTelemetry tracing via `telemetry: { isEnabled: true }`; uses `@opentelemetry/api` (optional peer dep); no-op when not configured.
- Cache control breakpoints are automatically injected into messages; providers that support caching (Anthropic) use them.

## Integration

- [Quick Start](./README.md): single-shot with Loop, multi-turn with Conversation
- [Architecture](./docs/architecture.md): Loop, dispatch, factories, sandbox, schemas
- [Tools Guide](./docs/tools.md): defining tools, AskRequired, formatToolMessage, lastResult, events
- [Events](./docs/events.md): outer `tool_call:*` events vs. tool-emitted `script:*`/`tool:*` events
- [Ask](./docs/approval.md): throwing AskRequired, resolving, rejecting, replay
- [Sandbox](./docs/sandbox.md): QuickJS execution, `sandboxTool` factory, parallel execution, deterministic replay
- [Hooks & Custom Inputs](./docs/hooks.md): beforeTurn, transformMessage, custom input types
- [Conversation](./docs/conversation.md): multi-turn history with automatic event forwarding
- [Lifecycle](./docs/lifecycle.md): prompt status, serialization, stop/resume, async ask flow
- [Observability](./docs/observability.md): console logging and OpenTelemetry tracing

## API Surface

- `Loop` — main class, extends EventEmitter, orchestrates the agentic conversation. `loop.prompt` returns the current partial Prompt; `loop.stop()` aborts execution; `loop.resolveAsk(answer)` resolves a pending ask; `loop.rejectAsk()` rejects a pending ask; `loop.pendingAsk` returns the current pending ask if any.
- `defineTool(config)` — create a tool with `{ id, description, inputSchema, outputSchema, handle, formatToolMessage? }` (schemas accept JSON Schema or zod).
- `createToolBuilder<TContext>()` — returns `{ defineTool, defineTools }` with typed context.
- `AskRequired` — error class thrown from `handle` to pause the loop. Constructor: `new AskRequired({ type, schema, data })`.
- `sandboxTool({ tools, toolTimeout?, id?, description? })` — built-in factory. Returns a `Tool` that runs TypeScript in QuickJS with `tools` exposed as globals. Supports replay/resume across asks.
- `describeTool({ tools, id?, description? })` — built-in factory. Returns a `Tool` that returns TypeScript declarations for the given tools.
- `sandboxAgentPrompt` — opt-in string of system-prompt guidance teaching an LLM how to operate the sandbox effectively.
- `buildSystemPrompt(tools, preamble?)` — generic system prompt lister; tool-agnostic.
- `executeSandbox(code, options)` — run TypeScript in QuickJS directly without the loop wrapper.
- `dispatchToolCall(opts)` — single-tool dispatch primitive used by the loop. Useful for building custom orchestrators.
- `promptToMessages(prompt, transformMessage?)` — convert a Prompt to OpenAI ChatCompletionMessageParam[].
- `outputToToolResponse(output)` — convert a `tool_call` output into the LLM-facing tool message body.
- `anthropicCacheStrategy(messages)` — inject Anthropic-style cache_control breakpoints.
- `toolsToTypeScript(tools)` — generate TypeScript declarations from tools.
- `Conversation` — multi-turn wrapper. Constructor `{ client, tools, model, systemPrompt?, ... }`. `conversation.turn({ input })` returns a ready `Loop`.
- `prompt(options)` — create a Prompt with defaults. Options: `{ input, model, systemPrompt?, temperature?, maxIterations?, maxTokens?, ... }`.
- `resolvePromptAsk(prompt, answer)` / `rejectPromptAsk(prompt)` — set the askOutcome on a serialized prompt before constructing a Loop.
- `findAwaitingOutput(prompt)` — find the awaiting_ask `tool_call` output.

## Minimal Example — direct LLM tool

```typescript
import OpenAI from "openai";
import { Loop, defineTool, prompt } from "agentic";
import { z } from "zod";

const greet = defineTool({
  id: "greet",
  description: "Greet someone by name",
  inputSchema: z.object({ name: z.string() }),
  outputSchema: z.object({ message: z.string() }),
  async handle({ input }) { return { message: `Hello, ${input.name}!` }; },
});

const loop = new Loop({
  client: new OpenAI(),
  tools: [greet],
  history: [prompt({ input: "Greet Alice", model: "gpt-4" })],
});

loop.on("text:delta", (_id, delta) => process.stdout.write(delta));
const result = await loop.run();
```

## Minimal Example — sandbox + describe + direct, mixed

```typescript
import { Loop, prompt, sandboxTool, describeTool, sandboxAgentPrompt, buildSystemPrompt } from "agentic";

const sandboxTools = [readFile, writeFile, search];
const llmTools = [
  sandboxTool({ tools: sandboxTools }),
  describeTool({ tools: sandboxTools }),
  weatherDirect,                          // direct LLM tool, no sandbox
];

const loop = new Loop({
  client,
  tools: llmTools,
  history: [prompt({
    input: "Find files about deployment and summarize the latest weather",
    model: "gpt-4",
    systemPrompt: `${buildSystemPrompt(llmTools)}\n\n${sandboxAgentPrompt}`,
  })],
});
```

## Human-in-the-loop pattern

```typescript
import { defineTool, AskRequired, ToolError } from "agentic";

const deploy = defineTool({
  id: "deploy",
  description: "Deploy to production",
  inputSchema: z.object({ version: z.string() }),
  outputSchema: z.object({ url: z.string() }),
  async handle({ input, ask }) {
    if (!ask) {
      throw new AskRequired({
        type: "approval",
        schema: { type: "object", properties: { approved: { type: "boolean" } }, required: ["approved"] },
        data: { reason: `Deploy ${input.version} to prod` },
      });
    }
    if (ask.type === "rejected") throw new ToolError("rejected");
    return { url: `https://prod/${input.version}` };
  },
});
```

## LoopOptions

```typescript
{
  client: OpenAI,                                          // Required
  tools: Tool[],                                           // Required — direct LLM tools (use factories for sandbox/describe)
  context?: ToolContext,                                   // Passed to tool handlers
  history: Prompt[],                                       // Required — last entry is the current prompt
  outputSchema?: z.ZodType<TOutput>,                       // Structured output schema
  signal?: AbortSignal,                                    // Abort the loop
  beforeTurn?: BeforeTurnHook | BeforeTurnHook[],          // Modify history/model/systemPrompt per-turn
  transformMessage?: TransformMessage,                     // Refine per-prompt message conversion
  logLevel?: "debug" | "info" | "warn" | "error" | "off", // Console log level
  telemetry?: { isEnabled: boolean },                      // Enable OpenTelemetry spans
}
```
