Skip to content

Commit 4e874f4

Browse files
committed
feat: introduce research briefs and self-harness functionality
- Added support for generating research briefs, allowing users to create cited markdown documents. - Implemented a self-harness mechanism for recording failures and proposing YAML patches, requiring human approval for irreversible actions. - Updated CLI to include commands for brief generation and harness management. - Enhanced README and documentation to reflect new features and usage instructions.
1 parent b2e2cb2 commit 4e874f4

20 files changed

Lines changed: 993 additions & 17 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ mutants/
3737
# Local agent state
3838
.ai_agent/
3939

40+
# Generated research briefs and Self-Harness proposals
41+
briefs/
42+
!briefs/.gitkeep
43+
proposals/
44+
!proposals/.gitkeep
45+
4046
# Type checking
4147
.mypy_cache/
4248
.dmypy.json

DECISIONS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,21 @@ Survivors are expected on the first baseline — many are equivalent mutations (
7878

7979
---
8080

81+
## Personal Operator first
82+
83+
**Chose Research Desk before shopping/trading.** Same harness DNA (tools, memory, citations, light HITL) with a vertical that needs no Gmail/Calendar/checkout. Product surface: `operator.yaml` + `ai-agent brief`. Irreversible actions go through `request_approval` rather than a new `AgentDecision.kind`, keeping the decision schema stable.
84+
85+
## Self-Harness moonshot (experimental)
86+
87+
**Chose a guarded scaffold, not unsupervised self-modification.** Failures are mined into `HarnessPatch` surfaces limited to YAML (`system_prompt` append, `max_tool_rounds`). Accept runs pytest and requires a human CLI step. Arbitrary Python edits and auto-merge are out of v0 — matches “study while touching code” without claiming AGI. Framing and reading list live in the README moonshot section.
88+
8189
## What this is not
8290

8391
- Not an observability platform
8492
- Not tied to a single framework beyond OpenAI-compatible HTTP
8593
- Not a customer-support field collector (that can be a *tool* or a separate config, not the core)
8694
- Not a free-form multi-agent mesh (synchronous coordinator→specialist ask only)
95+
- Not an unsupervised self-modifying agent (Self-Harness patches are human-gated config only)
8796

8897
---
8998

README.md

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ Everything below is **deterministic software around the model** (OpenRouter toda
4747
| **Serving surfaces** | Done | Console CLI, WebSocket chat (`--server`), multi-agent demo | [`cli.py`](src/ai_agent/cli.py) · [`infrastructure/server.py`](src/ai_agent/infrastructure/server.py) |
4848
| **Guardrails (light)** | Done | URL scheme allowlist, path jail, AST calculator, typed config | `url_safety` · `workspace_fs` · `calculator` |
4949
| **Provider portability** | Done | `LLMPort` + httpx OpenRouter adapter (no SDK lock-in) | [`infrastructure/llm.py`](src/ai_agent/infrastructure/llm.py) |
50+
| **Research Desk** | Done | Personal Operator persona + `brief` → cited markdown under `briefs/` | [`config/agents/operator.yaml`](config/agents/operator.yaml) · [`application/brief.py`](src/ai_agent/application/brief.py) |
51+
| **HITL approvals** | Done | `request_approval` tool + optional `brief --approve` (console Y/n) | [`tools/request_approval.py`](src/ai_agent/tools/request_approval.py) · [`infrastructure/approval.py`](src/ai_agent/infrastructure/approval.py) |
52+
| **Self-Harness (experimental)** | Scaffold | Mine failures → propose YAML patches → human `accept` after pytest | [`application/self_harness.py`](src/ai_agent/application/self_harness.py) |
5053
| **Context compaction** || Full history in window today | Roadmap |
51-
| **HITL approvals** || No human-in-the-loop gates yet | Roadmap |
5254
| **OS / container sandbox** || Scoped limits only (not Docker/Firecracker) | Roadmap |
5355

5456
**Model (not harness):** whatever you set in YAML (`openai/gpt-4o-mini`, etc.) via OpenRouter.
@@ -112,9 +114,52 @@ ai-agent -c config/agent_config.yaml
112114
| Mode | Command | Notes |
113115
|---|---|---|
114116
| Console | `uv run ai-agent -c config/agent_config.yaml` | Single agent |
117+
| Research operator | `uv run ai-agent -c config/agents/operator.yaml` | Interactive Research Desk |
118+
| One-shot brief | `uv run ai-agent brief "agent harness"` | Writes `briefs/YYYYMMDD_slug.md` |
119+
| Brief + approve | `uv run ai-agent brief "topic" --approve` | Console Y/n before write |
115120
| WebSocket | `uv run ai-agent --server -v` then `websocat ws://localhost:8765` | Chat-style plain text replies |
116121
| Multi-agent | `uv run ai-agent --multi-agent -v` | Coordinator + researcher handoff |
117122
| RAG ingest | `uv run ai-agent ingest --docs docs/` | Needs `[rag]` extra |
123+
| Harness propose | `uv run ai-agent harness propose` | Mine `.ai_agent/failures``proposals/` |
124+
| Harness accept | `uv run ai-agent harness accept <id>` | Pytest gate, then merge into YAML |
125+
126+
---
127+
128+
## Personal Operator / Research Desk
129+
130+
First product vertical on this harness: a **research operator** that turns a topic into a cited brief.
131+
132+
```bash
133+
uv run ai-agent brief "agent harness"
134+
# → briefs/20260731_agent-harness.md (Summary / Key findings / Sources / Open questions)
135+
136+
uv run ai-agent -c config/agents/operator.yaml # interactive
137+
```
138+
139+
The operator prefers `web_search``http_get` → local `retrieve` / `workspace_search`, remembers prefs via `memory` (`pref.*` keys), and never invents sources. Optional `--approve` gates publication. Irreversible future actions use the `request_approval` tool.
140+
141+
---
142+
143+
## Moonshot: Self-Harness
144+
145+
Industry frontier (not “solved”): a fixed model improves the **software around itself** — prompts, tool descriptions, loop budgets — from execution evidence, without weight updates. Canonical loop: weakness mining → harness proposal → validation (held-in improves, held-out does not regress).
146+
147+
**Reading path**
148+
149+
| Resource | Why |
150+
|---|---|
151+
| [Self-Harness (arXiv)](https://arxiv.org/abs/2606.09498) | Core paradigm + results |
152+
| [Lil’Log — Harness Engineering for Self-Improvement](https://lilianweng.github.io/posts/2026-07-04-harness/) | Map of self-improvement vs weight updates |
153+
| [LangChain — Anatomy of an Agent Harness](https://www.langchain.com/blog/the-anatomy-of-an-agent-harness) | Agent = Model + Harness vocabulary |
154+
| [Anthropic — Effective harnesses for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) | Long-horizon reliability |
155+
156+
**Scaffold in this repo (human-gated):** failures auto-log on step exceptions; `harness propose` writes a JSON patch (prompt append / `max_tool_rounds` only); `harness accept` runs a pytest subset then merges into YAML. **No auto-merge. No arbitrary Python edits in v0.**
157+
158+
```bash
159+
uv run ai-agent harness record-failure "Timed out waiting for researcher"
160+
uv run ai-agent harness propose
161+
uv run ai-agent harness accept patch_… -c config/agents/operator.yaml
162+
```
118163

119164
---
120165

@@ -131,6 +176,7 @@ ai-agent -c config/agent_config.yaml
131176
| `current_time` | Clock / timezone |
132177
| `note` | Ephemeral scratchpad (demo) |
133178
| `message_agent` | Ask another runtime agent (multi-agent) |
179+
| `request_approval` | Pause for human Y/n before irreversible actions |
134180

135181
Enable tools by name in YAML — the registry resolves them at composition time.
136182

@@ -216,9 +262,12 @@ Survivors are expected on a first baseline (many are equivalent string/bound twe
216262
| Area | Maturity |
217263
|---|---|
218264
| Single-agent ReAct harness | Production-shaped reference |
265+
| Research Desk / Personal Operator | Shipped (`brief` + operator YAML) |
219266
| Multi-agent coordinator / researcher | Demo-ready |
220267
| RAG | Optional extra; local Chroma |
221-
| Observability / HITL / heavy sandbox | Intentionally out of scope (for now) |
268+
| HITL approvals | Light scaffold (`request_approval` + brief `--approve`) |
269+
| Self-Harness | Experimental propose/accept only — human gate required |
270+
| Observability / heavy sandbox | Intentionally out of scope (for now) |
222271

223272
---
224273

config/agents/operator.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
model: openai/gpt-4o-mini
2+
system_prompt: >
3+
You are the Personal Operator / Research Desk agent.
4+
Turn topics into accurate, cited research briefs. Prefer tools over guessing.
5+
6+
Research workflow:
7+
1. Check memory for user preferences (keys pref.*).
8+
2. Use web_search for public topics, then http_get on the best URLs.
9+
3. Use retrieve / workspace_search only for local project docs.
10+
4. Never invent sources — every claim should map to a tool observation or URL.
11+
12+
When writing a brief, structure the final respond message as markdown with:
13+
## Summary
14+
## Key findings
15+
## Sources
16+
## Open questions
17+
18+
Use request_approval before any irreversible side effect (future: send email, buy, publish).
19+
max_tool_rounds: 8
20+
personality:
21+
tone: professional
22+
style: concise
23+
greeting: "Research desk online. Give me a topic and I'll produce a cited brief."
24+
workspace_root: "."
25+
sqlite_path: ".ai_agent/operator.db"
26+
chroma_path: ".ai_agent/chroma"
27+
tools:
28+
- web_search
29+
- http_get
30+
- retrieve
31+
- workspace_search
32+
- memory
33+
- calculator
34+
- current_time
35+
- request_approval
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
"""Application layer — agent use-cases and tool registry."""
22

33
from ai_agent.application.agent import Agent
4+
from ai_agent.application.brief import run_research_brief
45
from ai_agent.application.loop import LLMPort, build_system_prompt, run_tool_loop
56
from ai_agent.application.registry import ToolRegistry
7+
from ai_agent.application.self_harness import (
8+
accept_harness_patch,
9+
propose_harness_patch,
10+
record_failure,
11+
)
612
from ai_agent.application.tool_args import ArgValidationResult, validate_tool_arguments
713

814
__all__ = [
915
"Agent",
1016
"ArgValidationResult",
1117
"LLMPort",
1218
"ToolRegistry",
19+
"accept_harness_patch",
1320
"build_system_prompt",
21+
"propose_harness_patch",
22+
"record_failure",
23+
"run_research_brief",
1424
"run_tool_loop",
1525
"validate_tool_arguments",
1626
]

src/ai_agent/application/agent.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
import logging
66

7-
from aioconsole import ainput
7+
from aioconsole import ainput, aprint
88

99
from ai_agent.application.loop import LLMPort, run_tool_loop
1010
from ai_agent.application.registry import ToolRegistry
11+
from ai_agent.console_io import restore_blocking_stdio
1112
from ai_agent.domain.models import AgentConfig, StepResult
1213
from ai_agent.domain.state import ConversationState
1314

@@ -70,6 +71,17 @@ async def step(
7071
except Exception as exc:
7172
logger.exception("agent_step_failed agent_id=%s", self.agent_id)
7273
session.mark_done()
74+
try:
75+
from ai_agent.application.self_harness import record_failure
76+
77+
record_failure(
78+
agent_id=self.agent_id,
79+
message=str(exc),
80+
tool_traces=[t.model_dump() for t in session.tool_traces],
81+
context_summary=f"messages={len(session.messages)}",
82+
)
83+
except Exception: # noqa: BLE001 — never block error path on logging
84+
logger.debug("failure_record_skipped", exc_info=True)
7385
return StepResult(
7486
message=f"Something went wrong: {exc}",
7587
kind="error",
@@ -80,11 +92,14 @@ async def run(self, session: ConversationState | None = None) -> None:
8092
"""Standalone console loop (owns its own stdin/stdout)."""
8193
session = session or self.create_session()
8294
result = await self.step(session, user_input=None)
83-
print(f"Assistant: {result.message}")
95+
# ainput leaves stdout non-blocking; restore before large writes.
96+
restore_blocking_stdio()
97+
await aprint(f"Assistant: {result.message}", flush=True)
8498

8599
while not session.done:
86100
user_input = await ainput("You: ")
87101
result = await self.step(session, user_input=user_input)
88-
print(f"Assistant: {result.message}")
102+
restore_blocking_stdio()
103+
await aprint(f"Assistant: {result.message}", flush=True)
89104
if result.kind in {"done", "error"}:
90105
break

src/ai_agent/application/brief.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Research brief use-case — run the operator once and persist markdown."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import logging
7+
import re
8+
from datetime import UTC, datetime
9+
from pathlib import Path
10+
11+
from ai_agent.application.agent import Agent
12+
from ai_agent.domain.models import StepResult
13+
from ai_agent.domain.ports import ApprovalGate
14+
from ai_agent.domain.state import ConversationState
15+
16+
logger = logging.getLogger(__name__)
17+
18+
BRIEF_PROMPT_TEMPLATE = """Produce a research brief on the following topic.
19+
20+
Topic: {topic}
21+
22+
Requirements:
23+
- Use tools (web_search, http_get, retrieve, workspace_search) as needed.
24+
- Do not invent sources.
25+
- Final respond message MUST be markdown with these sections:
26+
## Summary
27+
## Key findings
28+
## Sources
29+
## Open questions
30+
"""
31+
32+
33+
def slugify(topic: str, *, max_len: int = 60) -> str:
34+
"""Filesystem-safe slug from a research topic."""
35+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", topic.strip().lower()).strip("-")
36+
if not cleaned:
37+
cleaned = "brief"
38+
return cleaned[:max_len].rstrip("-")
39+
40+
41+
def render_brief_markdown(
42+
topic: str,
43+
result: StepResult,
44+
*,
45+
include_tool_traces: bool = True,
46+
) -> str:
47+
"""Compose the on-disk brief from the agent step result."""
48+
stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
49+
parts = [
50+
f"# Research brief: {topic}",
51+
"",
52+
f"_Generated {stamp} · rounds={result.rounds_used} · kind={result.kind}_",
53+
"",
54+
result.message.strip() or "(empty response)",
55+
"",
56+
]
57+
if include_tool_traces and result.tool_results:
58+
parts.extend(
59+
[
60+
"## Tool traces",
61+
"",
62+
"```json",
63+
json.dumps(result.tool_results, indent=2),
64+
"```",
65+
"",
66+
]
67+
)
68+
return "\n".join(parts)
69+
70+
71+
async def run_research_brief(
72+
topic: str,
73+
*,
74+
agent: Agent,
75+
session: ConversationState | None = None,
76+
output_dir: Path,
77+
approval_gate: ApprovalGate | None = None,
78+
require_approval: bool = False,
79+
) -> Path:
80+
"""
81+
Run one operator turn for ``topic`` and write a markdown brief.
82+
83+
When ``require_approval`` is True, asks ``approval_gate`` before writing.
84+
"""
85+
cleaned = topic.strip()
86+
if not cleaned:
87+
raise ValueError("topic must be non-empty")
88+
89+
session = session or agent.create_session()
90+
# Skip deterministic greeting for one-shot briefs.
91+
session.greeting_sent = True
92+
93+
prompt = BRIEF_PROMPT_TEMPLATE.format(topic=cleaned)
94+
result = await agent.step(session=session, user_input=prompt)
95+
96+
if result.kind == "error":
97+
raise RuntimeError(result.message)
98+
99+
if require_approval:
100+
if approval_gate is None:
101+
raise ValueError("require_approval=True needs an ApprovalGate")
102+
approved = await approval_gate.request(
103+
f"Publish research brief on {cleaned!r} to {output_dir}?"
104+
)
105+
if not approved:
106+
raise PermissionError("Brief publication declined by user")
107+
108+
output_dir.mkdir(parents=True, exist_ok=True)
109+
stamp = datetime.now(UTC).strftime("%Y%m%d")
110+
path = output_dir / f"{stamp}_{slugify(cleaned)}.md"
111+
path.write_text(render_brief_markdown(cleaned, result), encoding="utf-8")
112+
logger.info("research_brief_written path=%s", path)
113+
return path

0 commit comments

Comments
 (0)