From a847f9ee567c70878275c3c007c897d42a76b12f Mon Sep 17 00:00:00 2001 From: Flint Date: Thu, 26 Feb 2026 23:36:38 -0800 Subject: [PATCH 1/4] test: add provider response mapping tests (17 tests) Covers all 4 providers (Anthropic, OpenAI, Ollama, Google): - Tool call parsing from each provider's response format - Text-only responses - Multiple tool calls - Mixed text + tool responses (Google) - Tool schema formatting (Anthropic/OpenAI/Ollama/Google) - Edge cases (malformed JSON, empty content) --- packages/agent/test/provider.test.ts | 428 +++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 packages/agent/test/provider.test.ts 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(); + }); +}); From 9add5c1b9f29562c5a76f66b5249ceaa8abeb0ed Mon Sep 17 00:00:00 2001 From: Flint Date: Thu, 26 Feb 2026 23:37:44 -0800 Subject: [PATCH 2/4] test: provider tests + docs: README rewrite - 17 new provider response mapping tests covering all 4 providers (Anthropic, OpenAI, Ollama, Google), tool schema formatting, and edge cases - README rewrite reflecting current state: tps-agent runtime, Docker office architecture, Ollama/multi-provider support, quickstart with real examples --- README.md | 174 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 130 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 55015f4..676e289 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,164 @@ # TPS (Team Provisioning System) -> "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." +> "Yeah... I'm gonna need you to go ahead and come in on Saturday." -**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. -![Lumbergh Agent](docs/media/lumbergh-agent.png) - ## Why TPS? 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 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 From b2119a20e4f31ca37f7e73272ee5bb5469a3c6ce Mon Sep 17 00:00:00 2001 From: Flint Date: Thu, 26 Feb 2026 23:39:54 -0800 Subject: [PATCH 3/4] fix: restore Office Space quotes and Lumbergh image in README --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 676e289..1c8f730 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ # TPS (Team Provisioning System) -> "Yeah... I'm gonna need you to go ahead and come in on Saturday." +> "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.** 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. +![Lumbergh Agent](docs/media/lumbergh-agent.png) + ## Why TPS? 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. +> "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 From 0566a1cc373ffb77fcd2a401af46db5dc9b253db Mon Sep 17 00:00:00 2001 From: Flint Date: Thu, 26 Feb 2026 23:43:17 -0800 Subject: [PATCH 4/4] fix: align ASCII box diagram in README All lines now 47 display columns. Widened inner boxes so /workspace/coder has proper spacing. --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1c8f730..72b1379 100644 --- a/README.md +++ b/README.md @@ -103,20 +103,20 @@ llm: 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 under nono sandbox │ -│ - Drops privileges after setup │ -└─────────────────────────────────────────┘ +┌─────────────────────────────────────────────┐ +│ 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