Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,22 @@
## Setup

```bash
pip install -e ".[dev]"
python -m pip install -e ".[dev]"
```

### Running tests
```bash
# All unit tests (excludes integration tests needing API keys)
pytest tests/ --ignore=tests/test_integration.py
python -m pytest tests/ --ignore=tests/test_integration.py

# Single test file
pytest tests/test_tools.py -v
python -m pytest tests/test_tools.py -v

# Single test
pytest tests/test_tools.py::test_execute_tool_timeout -v
python -m pytest tests/test_tools.py::test_execute_tool_timeout -v

# Integration tests (needs ANTHROPIC_API_KEY or DEEPSEEK_API_KEY)
pytest tests/test_integration.py -v -m integration
python -m pytest tests/test_integration.py -v -m integration

# Run the agent CLI
python -m tiny_harness "hello"
Expand All @@ -35,18 +35,17 @@ ruff check --fix tiny_harness/ tests/

## Architecture

**Public API** (`tiny_harness/__init__.py`): `Agent`, `Prompt`, `Config`, `ToolDef`
**Public API** (`tiny_harness/__init__.py`): `Agent`, `Prompt`, `Config`, `ToolDef`, `SessionStore`

**Module dependency order** (no cycles):
```
_config.py → _events.py → _guard.py → _llm.py → _messages.py → _tools.py → _loop.py → _core.py
↑ │
└─── cli.py, tui.py import only _core.py (never modules directly) ───────────────────┘
_config.py → _events.py → _guard.py → _sse.py/_llm_response.py → _llm.py
→ _messages.py → _hitl.py/_tools.py → _loop.py/_persist.py → _core.py
```

- `_core.py` (117 lines) — Agent class, the only integrator. Creates all components at `__init__`. Never add new cross-module wiring elsewhere.
- `_loop.py` (109 lines) — AgentLoop state machine. Zero intelligence: calls LLM, executes tools, loops. Never add planning logic here.
- `_llm.py` (341 lines) — All provider code (AnthropicProvider + OpenAIProvider + types). If adding a new provider, match the `LLMProvider` ABC.
- `_core.py` (~300 lines) — Agent class, the only integrator. Creates all components at `__init__`. Never add new cross-module wiring elsewhere.
- `_loop.py` (~130 lines) — AgentLoop state machine. Zero intelligence: calls LLM, executes tools, loops. Never add planning logic here.
- `_llm.py` (~350 lines) — Provider adapters and types; SSE decoding lives in `_sse.py`. New providers must match `LLMProvider`.
- `tools/` and `skills/` — each is standalone. `tools/` has handlers (`def my_tool(args: dict) -> str`). `skills/` has `register(agent)` that wires tools + prompt sections together.

---
Expand All @@ -55,7 +54,7 @@ _config.py → _events.py → _guard.py → _llm.py → _messages.py → _tools.

| Term | Meaning |
|---|---|
| Agent | The whole runtime. Created, given tasks, discarded. |
| Agent | The whole runtime, scoped to a Session and reusable across multiple `run()` calls. |
| Agent Core | Loop + Messages + Config + LLMProvider + empty ToolRegistry. Zero tools built in. |
| Tool | External plugin registered into ToolRegistry. Handler signature: `def handler(args: dict) -> str`. |
| Prompt | First-class artifact, separate from `Config`. `Prompt("base").append("section")`. |
Expand Down Expand Up @@ -111,11 +110,15 @@ _config.py → _events.py → _guard.py → _llm.py → _messages.py → _tools.
| `tiny_harness/_core.py` | Agent class — orchestrator |
| `tiny_harness/_loop.py` | AgentLoop — while-loop state machine |
| `tiny_harness/_llm.py` | All LLM providers + types |
| `tiny_harness/_sse.py` | Anthropic/OpenAI SSE decoding and tool-call buffering |
| `tiny_harness/_llm_response.py` | Anthropic response object adapters |
| `tiny_harness/_tools.py` | ToolRegistry, ToolExecutor, schema validator, ToolResult |
| `tiny_harness/_messages.py` | MessageManager, TokenBudget |
| `tiny_harness/_config.py` | AgentConfig, Prompt |
| `tiny_harness/_events.py` | StreamEvent, EventBus |
| `tiny_harness/_guard.py` | FilesystemGuard |
| `tiny_harness/_hitl.py` | Human approval policy and session approvals |
| `tiny_harness/_persist.py` | Atomic JSONL Session persistence |
| `tiny_harness/cli.py` | CLI entry point |
| `tiny_harness/tui.py` | Rich TUI mode |
| `tiny_harness/tools/files.py` | File tool handlers (7 tools) |
Expand Down
10 changes: 5 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
### Agent
The complete runtime system that wraps an LLM, gives it tools, and executes the agent loop until a final answer is produced. The Agent owns: the Loop (while-loop orchestrator), the ToolRegistry (available tools), the LLMProvider (model interface), the MessageManager (context/memory), and the Config (identity + constraints).

Usage: `agent = Agent(config); result = await agent.run("Do X")`
Usage: `agent = Agent(prompt=Prompt("..."), config=config); result = await agent.run("Do X")`

The Agent is the only user-facing entry point. Internally, the Loop is the mechanical orchestrator; the Agent is the whole thing.

Expand All @@ -15,19 +15,19 @@ The Agent is the only user-facing entry point. Internally, the Loop is the mecha
The components that ship with the Agent and are essential for it to function: the Loop, MessageManager, Config, LLMProvider, and an empty ToolRegistry. The Agent Core has zero tools by default — even file access must be registered.

### Tool
A capability registered into the Agent's ToolRegistry. Tools are external plugins — they do not ship with the Agent Core. A convenience helper (`register_file_tools(agent)`) provides standard filesystem tools, but the user always controls which tools are available.
A capability registered into the Agent's ToolRegistry. Tools are external plugins — they do not ship with the Agent Core. Built-in Skills such as `agent.load_skill("files")` provide standard tools, but the user controls which Skills are loaded.

**Distinction**: The ToolRegistry is part of the Agent Core (it's the container). Individual Tools are not — they are registered into the registry by the user.

### Prompt
The agent's personality and operational rules — the system-level instructions that define how the Agent behaves, when to use tools, and what constraints to follow. The Prompt is a first-class design artifact, separate from Config (which is parameters like model, iterations) and from MessageManager (which manages runtime conversation state).

Usage: A Prompt may be a string, a file reference, or a template. It is version-controlled, tested, and swapped independently of the Agent's runtime parameters.
Usage: A Prompt currently wraps text and appended sections. File references and templates are future extensions.

**Not to be confused with**: the User Prompt (the task given to the Agent at runtime) or Config (numeric/boolean parameters like `max_iterations`).

### Workspace
The root directory that bounds the Agent's filesystem authority. All relative paths resolve against the Workspace. The FilesystemGuard enforces that no file operation escapes the Workspace. The Workspace is the Agent's "home" — it can read, write, and organize within it, but cannot access anything outside.
The root directory that bounds filesystem-aware Tools. All relative path fields resolve against the Workspace, and FilesystemGuard prevents those operations from escaping it. Workspace is not an OS sandbox: a deliberately enabled `dangerous` shell Tool still runs with the host process's authority and is controlled through HITL approval.

Usage: Set once at Agent creation. The Agent treats the Workspace as `/` — it doesn't know (and shouldn't care) about the absolute path on the host filesystem.

Expand All @@ -36,7 +36,7 @@ Usage: Set once at Agent creation. The Agent treats the Workspace as `/` — it
### Conversation
The full ordered history of messages exchanged during an Agent run: the system-level Prompt, the user's task, the LLM's responses (text + tool call requests), and the tool results. The Conversation IS the Agent's memory — everything the LLM knows about the current interaction lives in this sequence. The MessageManager owns the Conversation; all other components read from it or append to it.

Usage: The Conversation starts with Prompt + user task and grows monotonically as the Loop executes. When the Agent completes, the Conversation is discarded (single-use Agent). Context management (truncation, compaction) operates on the Conversation.
Usage: The Conversation starts with Prompt + user task and grows across `run()` calls in the same Session. `clear()` resets it to the current system Prompt.

### Session
A continuous interaction between the user and Agent that spans multiple user prompts. Within a Session, the Conversation persists — each new user prompt appends to the same Conversation, giving the Agent memory of what happened earlier in the Session. The Agent is Session-scoped: created when the Session starts, serves multiple user prompts, and discarded when the Session ends.
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
> 中文一句话:极小的 AI Agent 执行环境(harness),给 LLM 配上工具、对话循环、流式事件与 CLI 会话。强调可读性、显式状态和可测试的执行契约,是 1998x-stack harness 家族的最小基线。

[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/)
[![Python tests](https://github.com/1998x-stack/tiny-harness/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/1998x-stack/tiny-harness/actions/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-271%20passed-brightgreen)](.)
[![Lines](https://img.shields.io/badge/code-%7E2%2C000%20lines-lightgrey)](.)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)

`tiny-harness` is a small Python agent runtime with tools, a conversation loop, streaming events, and CLI sessions. It uses explicit contracts for tool validation, filesystem paths, and success/failure status.
`tiny-harness` is a compact Python package that turns an LLM into an AI agent—giving it tools, a conversation loop, streaming events, and a CLI session.

```bash
pip install tiny-harness
Expand Down Expand Up @@ -47,8 +48,8 @@ tiny-harness "Create hello.py"
- **CLI** — session REPL with streaming output, one-shot mode for scripts. Files skill loaded by default.
- **Rich TUI** — optional `--tui` mode with panels, markdown rendering, color-coded messages (`pip install tiny-harness[tui]`)
- **Persistence** — JSONL session history with `/save` and `/history` commands
- **Filesystem guard** — workspace boundary enforcement, path traversal protection
- **Inspectable contracts** — explicit tool risk classification, workspace path declarations, and JSON Schema Draft 2020-12 validation
- **Filesystem guard** — canonical Workspace paths and traversal protection for filesystem-aware Tools
- **Compact modules** — source modules stay below 350 lines and are readable top-to-bottom

## Quick Start

Expand Down Expand Up @@ -147,15 +148,17 @@ Agent.run(prompt)

```
tiny_harness/
├── __init__.py # Public API: Agent, Prompt, Config, ToolDef
├── __init__.py # Public API: Agent, Prompt, Config, ToolDef, SessionStore
├── _core.py # Agent class — orchestrator, session management
├── _loop.py # AgentLoop — state machine, error budget, loop detection
├── _llm.py # LLMProvider ABC + AnthropicProvider + OpenAIProvider
├── _sse.py # Provider-native SSE delta decoding
├── _messages.py # MessageManager — conversation, token counting
├── _tools.py # ToolRegistry + ToolExecutor + schema validator
├── _config.py # AgentConfig, Prompt dataclasses
├── _events.py # StreamEvent types + EventBus
├── _guard.py # FilesystemGuard — workspace boundaries
├── _persist.py # Atomic JSONL Session snapshots
├── cli.py # CLI entry point
├── tools/files.py # File tool handlers (read, write, list, find, delete, mkdir, move)
└── skills/files.py # register(agent) → file tools + prompt instructions
Expand Down Expand Up @@ -206,7 +209,7 @@ agent.tools.register_from_def(
description="Get current weather for a city.",
parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
),
handler=lambda args: "Sunny, 22°C in " + args["city"],
handler=lambda args: f"Sunny, 22°C in {args['city']}",
)

# Load built-in skills
Expand Down
21 changes: 10 additions & 11 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ This uses `difflib.get_close_matches` with a 0.6 cutoff. Names very different fr
| `mutation` | Checks path is writable within workspace |
| `destructive` | Strictest — checks path for delete operations |

The guard extracts the path from `args["path"]`, `args["source"]`, or `args["destination"]`. If your tool uses a different key, the guard won't apply.
The guard validates and canonicalizes every conventional path field present: `path`, `source`, `destination`, and `cwd`. If a custom Tool uses a different key, it must enforce its own path policy.

### Tool timeouts are per-call, not cumulative

Each tool call has its own timeout (default 30s). A task requiring 10 tool calls can run for 10 × 30 = 300 seconds total. The iteration cap (default 25) is the primary time-gate.
Each Tool call has its own timeout (default 30s). Synchronous handlers run in a worker thread so they do not block the Agent Loop. Timing out stops waiting; Python cannot forcibly terminate arbitrary work already running in that thread.

---

Expand All @@ -69,7 +69,7 @@ Stream failed: 400 'The `reasoning_content` in the thinking mode must be passed

### DeepSeek streaming tool calls arrive as deltas

Unlike Anthropic where tool calls arrive complete in a single event, OpenAI-compatible APIs (DeepSeek, OpenAI) stream tool call arguments as JSON fragments. The `OpenAIProvider.generate_stream()` buffers these deltas and emits the complete `ToolCallRequest` at the end.
Anthropic and OpenAI-compatible APIs stream tool-call arguments as fragments. Each Provider Adapter buffers its native deltas and emits the same complete `ToolCallRequest` at `tool_call_end`.

### Anthropic uses separate `system` parameter

Expand Down Expand Up @@ -185,7 +185,7 @@ Negative offsets are clamped to 1. Offsets exceeding file length return an error

### Shell cwd validated against workspace

The `run_command` tool validates its `cwd` parameter against the workspace boundary. This prevents LLM-injected `cwd="/etc"` from bypassing the FilesystemGuard.
The ToolExecutor validates and canonicalizes `run_command.cwd` against the configured Workspace before invoking the shell handler.

### find_files pattern validated against path escapes

Expand Down Expand Up @@ -359,7 +359,7 @@ Shell builtins (`exit`, `cd`, `export`) work via shell=True fallback since they

### `/save` now dumps the full conversation to disk

The `/save` command calls `_dump_conversation()` which serializes all current messages (user prompts, assistant responses, tool calls, tool results) to a JSONL file. Previously, `/save` only initialized the session without writing existing messages — the file would be created empty.
The `/save` command calls the public `agent.save_session()` Interface. It atomically replaces the JSONL snapshot with the current Conversation, so repeated saves are idempotent.

### `start_session()` is idempotent

Expand All @@ -371,24 +371,24 @@ assert sid1 == sid2

Calling `start_session()` multiple times returns the same session ID and does NOT reset `_chat_id`.

### `_save_turn()` is a no-op until `start_session()` is called
### Active Sessions are snapshotted after each run

Both `agent.run()` and `agent.run_stream()` call `_save_turn()` internally, but it returns early if `_session_id` is None. The CLI/TUI `/save` commands call `start_session()` first. In the Python API, call it explicitly:
Once a Session has started, `agent.run()` and `agent.run_stream()` snapshot the complete Conversation after each turn, including Tool calls and results. You can also save explicitly:

```python
agent.start_session()
await agent.run("hello") # turn saved
await agent.run("world") # turn saved
agent._dump_conversation() # write all to file
session_id, turns = agent.save_session()
```

### Tool call format differs between memory and storage

Messages store tool calls as `{"function": {"name": ..., "arguments": "..."}}` (LLM format). The SessionStore expects `{"name": ..., "arguments": {...}}` (plain dicts). `_dump_conversation()` handles this conversion internally.
Messages store tool calls in the common LLM format; Session persistence stores structured arguments and preserves Tool call ids. `save_session()` handles this conversion internally.

### The loop now adds assistant messages even without tool calls

Previously, when the LLM returned text without calling tools, the assistant response was returned as a string but never added to `_messages`. It's now added via `add_assistant()`, so `_dump_conversation()` sees the complete conversation.
Final and degraded answers are appended to the Conversation before it is persisted.

---

Expand Down Expand Up @@ -437,4 +437,3 @@ config = Config(
max_tokens=4096, # limit response size (default: 16384)
)
```

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ requires-python = ">=3.11"
dependencies = ["httpx>=0.27.0", "jsonschema>=4.23,<5"]

[project.optional-dependencies]
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0"]
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "rich>=13.0.0", "ruff>=0.8.0"]
tui = ["rich>=13.0.0"]

[project.scripts]
Expand Down
Loading
Loading