diff --git a/README.md b/README.md index 55015f4..72b1379 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > "Yeah... I'm gonna need you to go ahead and come in on Saturday. We lost some people this week and we need to sort of play catch-up." -**TPS is an Agent OS CLI for managing isolated AI agents in remote branch offices.** It provides the secure primitives for agents to exist, discover each other, communicate asynchronously, and run in isolated environments (Docker sandboxes or remote VMs). +**TPS is an Agent OS CLI for managing isolated AI agents.** It provides the primitives for agents to exist, discover each other, communicate asynchronously, and run in sandboxed environments. If you want your AI agents to stop stepping on each other's toes and actually get some work done, you're going to need them to file their TPS reports. @@ -12,67 +12,157 @@ If you want your AI agents to stop stepping on each other's toes and actually ge Most agent frameworks assume all agents run in the same memory space. TPS assumes agents are employees: they work in different offices, they have different security clearances, and they communicate via mail. -- **The Branch Office**: Agents run in secure, remote sandboxes (VMs or Docker). Host keys never leave the host. -- **The Mailroom**: Async, persistent, cross-boundary messaging. -- **Wire Security**: All traffic over `wss://` is E2E encrypted and mutually authenticated using the **Noise_IK** protocol. -- **The TPS Report**: One `tps.yaml` file defines an agent's identity, capabilities, and mail handlers. - > "I have eight different bosses right now. So that means that when I make a mistake, I have eight different people coming by to tell me about it." — Make your agents communicate through a single, auditable mail interface instead. +- **Branch Offices**: Agents run in Docker containers with four layers of isolation: Docker → Linux users → [nono](https://github.com/lukehinds/nono) Landlock → BoundaryManager +- **The Mailroom**: Async, persistent, cross-boundary Maildir-based messaging +- **TPS Agent**: Native agent runtime with tool use, multi-provider LLM support, and session management +- **TPS Reports**: YAML-based agent configuration — identity, capabilities, LLM provider, tools + ## Quickstart ```bash -# 1. Install +# Install npm install -g @tpsdev-ai/cli -# 2. Init your host identity -tps identity init +# Verify +tps --help +tps roster list +``` -# 3. Create a branch office on a remote VM -# (On the VM) -npm install -g @tpsdev-ai/cli -tps branch init --listen 6458 --host my-vm.example.com +### Run an agent locally + +```bash +# Create an agent config +mkdir -p my-agent/.tps +cat > my-agent/.tps/agent.yaml << 'EOF' +id: my-agent +name: MyAgent +workspace: ./my-agent +systemPrompt: "You are a helpful assistant. Use your tools to complete tasks." +tools: [read, write, edit, exec, mail] +maxTurns: 8 +llm: + provider: ollama # or: anthropic, openai, google + model: qwen3:8b + baseUrl: http://localhost:11434 +EOF + +# Run a one-shot task +tps agent run --config my-agent/.tps/agent.yaml \ + --message "Write a hello.txt file with a greeting" + +# Or start as a daemon (waits for mail) +tps agent start --config my-agent/.tps/agent.yaml +``` -# 4. Join the branch office -# (On your host) -tps office join my-vm "tps://join?host=my-vm.example.com..." +### Run agents in a Docker office -# 5. Connect the persistent relay -tps office connect my-vm & +```bash +# Pull the office image +docker pull ghcr.io/tpsdev-ai/tps-office:latest -# 6. Send a memo -tps mail send my-vm "Did you get the memo about the TPS reports?" +# Start an office for an agent +ANTHROPIC_API_KEY=sk-... tps office start my-agent -# 7. Check the branch status -tps mail send my-vm "status" -tps mail check -``` +# Check status +tps office status my-agent -## The Three-Channel Model +# Stop +tps office stop my-agent +``` -Agents shouldn't do everything over a single chat thread. TPS enforces: -1. **Mail** for messages (commands, status, notifications). -2. **Git** for artifacts (code, specs, docs). -3. **APIs** for external data. +## Agent Runtime -![The Mailroom](docs/media/mailroom.png) +The `tps-agent` binary provides a native agent runtime with: -## Plugins & Handlers +- **5 built-in tools**: `read`, `write`, `edit`, `exec`, `mail` +- **4 LLM providers**: Anthropic, OpenAI, Google, Ollama +- **Tool-use loop**: Agent receives task → calls LLM → executes tools → returns result +- **Daemon mode**: Watches mailbox for incoming tasks +- **Session storage**: JSONL conversation history -Agents can declare `mailHandlers` in their `tps.yaml` manifest. The TPS branch daemon will automatically route incoming mail to the right handler based on regex patterns or sender allowlists. +### Agent Config (`agent.yaml`) ```yaml -name: deploy-bot -capabilities: - mail_handler: - exec: ./handler.sh - match: - bodyPattern: "^(deploy|status)" +id: coder +name: Coder +workspace: /workspace/coder +mailDir: /workspace/coder/mail +systemPrompt: "You are a coding agent." +tools: [read, write, edit, exec, mail] +maxTurns: 8 +llm: + provider: anthropic + model: claude-sonnet-4-20250514 + apiKey: ${ANTHROPIC_API_KEY} # env var interpolation +``` + +## Docker Office Architecture + +Each office is a single Docker container running multiple agents with layered isolation: + +``` +┌─────────────────────────────────────────────┐ +│ Docker Container │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ agent-lead │ │ agent-coder │ │ +│ │ UID 1001 │ │ UID 1002 │ │ +│ │ nono Landlock │ │ nono Landlock │ │ +│ │ /workspace/lead │ │ /workspace/coder │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ +│ tps-office-supervisor (PID 1) │ +│ · Creates per-agent Linux users │ +│ · Starts each agent under nono sandbox │ +│ · Drops privileges after setup │ +└─────────────────────────────────────────────┘ +``` + +- **Docker**: Container boundary +- **Linux users**: Per-agent UIDs prevent cross-agent file access +- **nono (Landlock)**: Kernel-level filesystem sandboxing — each agent can only access its own workspace +- **BoundaryManager**: Application-level path validation in tps-agent + +## Mail System + +Agents communicate asynchronously via Maildir: + +``` +/workspace/agent-id/mail/ +├── inbox/ +│ ├── new/ # Unread messages +│ └── cur/ # Processed messages +└── outbox/ + └── new/ # Messages to send (host relay delivers) ``` -## Architecture +Agents write to their outbox. A host-side relay validates the sender and delivers to the recipient's inbox. -Read [ARCHITECTURE.md](ARCHITECTURE.md) for details on the Noise_IK implementation, hub-and-spoke topology, and security boundaries. +## Commands + +``` +tps office start Start a Docker office +tps office stop Stop an office +tps office status Check office status +tps office list List all offices +tps agent run --config --message One-shot agent task +tps agent start --config Start agent daemon +tps agent health --config Health check +tps roster list List configured agents +tps hire Onboard a new agent +tps status System status +``` + +## Development + +```bash +git clone https://github.com/tpsdev-ai/cli.git +cd cli +bun install +bun run build +bun run test # 400+ tests +``` ## License diff --git a/packages/agent/test/provider.test.ts b/packages/agent/test/provider.test.ts new file mode 100644 index 0000000..23d1b1a --- /dev/null +++ b/packages/agent/test/provider.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, test } from "bun:test"; +import { ProviderManager } from "../src/llm/provider.js"; + +// We test the response mappers by calling complete() with mocked fetch. +// Each provider has a distinct response format — these tests ensure +// tool calls are normalized correctly into { id, name, input }. + +function makeProvider(provider: "anthropic" | "openai" | "ollama" | "google") { + return new ProviderManager({ + provider, + model: "test-model", + apiKey: "test-key", + }); +} + +// --- Anthropic --- + +describe("Anthropic response mapping", () => { + test("parses tool_use blocks", async () => { + const pm = makeProvider("anthropic"); + const raw = { + content: [ + { type: "text", text: "I'll write that file." }, + { + type: "tool_use", + id: "toolu_123", + name: "write", + input: { path: "hello.txt", content: "Hello!" }, + }, + ], + usage: { input_tokens: 100, output_tokens: 50 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [{ name: "write", description: "Write a file", input_schema: { type: "object", properties: {} } }], + }); + + expect(res.content).toBe("I'll write that file."); + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].id).toBe("toolu_123"); + expect(res.toolCalls![0].name).toBe("write"); + expect(res.toolCalls![0].input).toEqual({ path: "hello.txt", content: "Hello!" }); + expect(res.inputTokens).toBe(100); + expect(res.outputTokens).toBe(50); + }); + + test("handles text-only response (no tools)", async () => { + const pm = makeProvider("anthropic"); + const raw = { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 10, output_tokens: 5 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe("Done."); + expect(res.toolCalls).toBeUndefined(); + }); + + test("handles multiple tool calls", async () => { + const pm = makeProvider("anthropic"); + const raw = { + content: [ + { type: "tool_use", id: "t1", name: "read", input: { path: "a.txt" } }, + { type: "tool_use", id: "t2", name: "write", input: { path: "b.txt", content: "hi" } }, + ], + usage: { input_tokens: 0, output_tokens: 0 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.toolCalls).toHaveLength(2); + expect(res.toolCalls![0].name).toBe("read"); + expect(res.toolCalls![1].name).toBe("write"); + }); +}); + +// --- OpenAI --- + +describe("OpenAI response mapping", () => { + test("parses function tool calls", async () => { + const pm = makeProvider("openai"); + const raw = { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call_abc", + function: { + name: "exec", + arguments: '{"command":"ls -la"}', + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 200, completion_tokens: 30 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [{ name: "exec", description: "Run command", input_schema: { type: "object", properties: {} } }], + }); + + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].id).toBe("call_abc"); + expect(res.toolCalls![0].name).toBe("exec"); + expect(res.toolCalls![0].input).toEqual({ command: "ls -la" }); + expect(res.inputTokens).toBe(200); + expect(res.outputTokens).toBe(30); + }); + + test("handles text-only response", async () => { + const pm = makeProvider("openai"); + const raw = { + choices: [{ message: { content: "All done.", tool_calls: undefined } }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe("All done."); + expect(res.toolCalls).toBeUndefined(); + }); +}); + +// --- Ollama --- + +describe("Ollama response mapping", () => { + test("parses function tool calls (object arguments)", async () => { + const pm = makeProvider("ollama"); + const raw = { + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_xyz", + function: { + name: "write", + arguments: { path: "test.txt", content: "hello" }, + }, + }, + ], + }, + prompt_eval_count: 143, + eval_count: 100, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [{ name: "write", description: "Write a file", input_schema: { type: "object", properties: {} } }], + }); + + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].name).toBe("write"); + expect(res.toolCalls![0].input).toEqual({ path: "test.txt", content: "hello" }); + expect(res.inputTokens).toBe(143); + expect(res.outputTokens).toBe(100); + }); + + test("parses function tool calls (string arguments)", async () => { + const pm = makeProvider("ollama"); + const raw = { + message: { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_str", + function: { + name: "read", + arguments: '{"path":"config.yaml"}', + }, + }, + ], + }, + prompt_eval_count: 50, + eval_count: 20, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [{ name: "read", description: "Read a file", input_schema: { type: "object", properties: {} } }], + }); + + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].name).toBe("read"); + expect(res.toolCalls![0].input).toEqual({ path: "config.yaml" }); + }); + + test("handles text-only response", async () => { + const pm = makeProvider("ollama"); + const raw = { + message: { role: "assistant", content: "File written." }, + prompt_eval_count: 10, + eval_count: 5, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe("File written."); + expect(res.toolCalls).toBeUndefined(); + }); +}); + +// --- Google --- + +describe("Google response mapping", () => { + test("parses functionCall parts", async () => { + const pm = makeProvider("google"); + const raw = { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: "write", + args: { path: "out.txt", content: "data" }, + }, + }, + ], + }, + }, + ], + usageMetadata: { promptTokenCount: 80, candidatesTokenCount: 40 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [{ name: "write", description: "Write", input_schema: { type: "object", properties: {} } }], + }); + + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].name).toBe("write"); + expect(res.toolCalls![0].input).toEqual({ path: "out.txt", content: "data" }); + expect(res.inputTokens).toBe(80); + expect(res.outputTokens).toBe(40); + }); + + test("parses text-only response", async () => { + const pm = makeProvider("google"); + const raw = { + candidates: [ + { + content: { + parts: [{ text: "Here's your answer." }], + }, + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 8 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe("Here's your answer."); + expect(res.toolCalls).toBeUndefined(); + }); + + test("handles mixed text + functionCall parts", async () => { + const pm = makeProvider("google"); + const raw = { + candidates: [ + { + content: { + parts: [ + { text: "Let me check." }, + { functionCall: { name: "read", args: { path: "data.json" } } }, + ], + }, + }, + ], + usageMetadata: { promptTokenCount: 0, candidatesTokenCount: 0 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe("Let me check."); + expect(res.toolCalls).toHaveLength(1); + expect(res.toolCalls![0].name).toBe("read"); + }); +}); + +// --- Tool schema formatting --- + +describe("Tool schema formatting", () => { + const spec = { + name: "write", + description: "Write a file", + input_schema: { type: "object", properties: { path: { type: "string" } } }, + }; + + test("Anthropic format", () => { + const pm = makeProvider("anthropic"); + const fn = pm.toolInputSchemaFor("anthropic"); + const result = fn([spec]) as any[]; + expect(result[0].name).toBe("write"); + expect(result[0].input_schema).toBeDefined(); + expect(result[0].function).toBeUndefined(); + }); + + test("OpenAI format wraps in function object", () => { + const pm = makeProvider("openai"); + const fn = pm.toolInputSchemaFor("openai"); + const result = fn([spec]) as any[]; + expect(result[0].type).toBe("function"); + expect(result[0].function.name).toBe("write"); + expect(result[0].function.parameters).toBeDefined(); + }); + + test("Ollama uses OpenAI format", () => { + const pm = makeProvider("ollama"); + const fn = pm.toolInputSchemaFor("ollama"); + const result = fn([spec]) as any[]; + expect(result[0].type).toBe("function"); + expect(result[0].function.name).toBe("write"); + }); + + test("Google format uses functionDeclarations", () => { + const pm = makeProvider("google"); + const fn = pm.toolInputSchemaFor("google"); + const result = fn([spec]) as any; + expect(result.functionDeclarations).toHaveLength(1); + expect(result.functionDeclarations[0].name).toBe("write"); + }); +}); + +// --- Edge cases --- + +describe("Edge cases", () => { + test("safeJson handles malformed string", async () => { + const pm = makeProvider("openai"); + const raw = { + choices: [ + { + message: { + tool_calls: [ + { id: "c1", function: { name: "exec", arguments: "not json{" } }, + ], + }, + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0 }, + }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.toolCalls![0].input).toEqual({}); + }); + + test("handles empty/missing content gracefully", async () => { + const pm = makeProvider("anthropic"); + const raw = { content: [], usage: {} }; + + globalThis.fetch = async () => + new Response(JSON.stringify(raw), { status: 200 }); + + const res = await pm.complete({ + messages: [{ role: "user", content: "test" }], + tools: [], + }); + + expect(res.content).toBe(""); + expect(res.toolCalls).toBeUndefined(); + }); +});