mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
7cdc61cde4
This PR turns the script-first wiki example into a durable in-repo workflow for `init`, `ingest`, `query`, and `lint` while keeping Context Hub sync straightforward. Why this approach: - The example is intended to work without embedding-based retrieval infrastructure. Maintaining `wiki/index.md` and `log.md` as first-class artifacts gives the model enough structure to navigate existing knowledge directly. - Query and lint needed stronger guidance so wiki quality improves over time instead of drifting. What changed: 1. Runner structure - Refactored the workflow into focused modules for `init`, `ingest`, `query`, `lint`, `index`, and `log`, with shared dataclasses in `models` and a thin `wiki_runner.py` entrypoint. - Kept mode interfaces and output markers stable. 2. Query behavior - Added prompt guidance to read `wiki/index.md` first, then optionally inspect prior `wiki/query/*.md` pages as routing hints before grounding in canonical wiki pages. - Enforced evidence policy: query pages are discovery aids, not primary evidence; when only query-page support exists, the answer must state uncertainty. - Preserved the existing file/skip workflow and durable writes to `wiki/query/<slug>.md`. 3. Lint behavior - Expanded lint into an in-place health check over `/wiki/`: contradictions, stale/superseded claims, orphan pages, missing cross-references, and concept/page coverage gaps. - Kept this model-driven and prompt-only (no separate search API layer). 4. Index and log lifecycle - `wiki/index.md` is refreshed as a content-oriented catalog during write flows so query routing remains reliable. - `log.md` is runner-managed and append-only, using parseable headings (`## [YYYY-MM-DD] mode.phase | ...`) for ingest/query/lint interactions and outcomes. 5. Naming and CLI simplification - Removed `--topic`; display topic is now derived from `--repo`. - Renamed the example from `topic-wiki-runner` to `wiki-runner` and aligned user-facing wording from “topic wiki” to “wiki”. Areas to review carefully: - Query/lint prompt wording for routing and evidence-grounding rules. - `index` categorization and summary extraction heuristics. - `log` append semantics and entry-format stability. - CLI normalization behavior for `--repo` and `--owner` after removing `--topic`.
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Async Subagent Server — Agent Protocol over FastAPI.
|
||
|
||
A minimal self-hosted Agent Protocol server that exposes a Deep Agents
|
||
researcher as an async subagent. Any Deep Agents supervisor can connect
|
||
to this server using the AsyncSubAgent configuration.
|
||
|
||
Implements the endpoints the Deep Agents async subagent middleware calls
|
||
(via the LangGraph SDK):
|
||
|
||
POST /threads create a thread
|
||
POST /threads/{thread_id}/runs start (or interrupt+restart) a run
|
||
GET /threads/{thread_id}/runs/{run_id} poll run status
|
||
GET /threads/{thread_id} fetch thread (values.messages used on success)
|
||
POST /threads/{thread_id}/runs/{run_id}/cancel cancel a run
|
||
GET /ok health check
|
||
|
||
Persistence uses an in-memory SQLite database (no files, no setup required).
|
||
The schema is created automatically on startup.
|
||
|
||
Run:
|
||
ANTHROPIC_API_KEY=... uvicorn server:app --port 2024
|
||
|
||
Then point a Deep Agents supervisor at:
|
||
RESEARCHER_URL=http://localhost:2024
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sqlite3
|
||
import uuid
|
||
from contextlib import asynccontextmanager
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from dotenv import load_dotenv
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from langchain_anthropic import ChatAnthropic
|
||
from langchain_core.messages import HumanMessage
|
||
from langchain_core.tools import tool
|
||
|
||
load_dotenv(Path(__file__).parent / ".env")
|
||
|
||
# ── Database ──────────────────────────────────────────────────────────────────
|
||
|
||
# In-memory SQLite shared across all connections in this process.
|
||
_conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||
_conn.row_factory = sqlite3.Row
|
||
|
||
|
||
def _init_db() -> None:
|
||
"""Create the threads and runs tables if they don't already exist.
|
||
|
||
threads — one row per conversation thread
|
||
messages JSON array of {role, content} objects
|
||
values JSON object stored as the thread's final state (values.messages)
|
||
|
||
runs — one row per run attempt on a thread
|
||
status one of: pending | running | success | error | cancelled
|
||
"""
|
||
_conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS threads (
|
||
thread_id TEXT PRIMARY KEY,
|
||
created_at TEXT NOT NULL,
|
||
messages TEXT NOT NULL DEFAULT '[]',
|
||
values_ TEXT NOT NULL DEFAULT '{}'
|
||
);
|
||
CREATE TABLE IF NOT EXISTS runs (
|
||
run_id TEXT PRIMARY KEY,
|
||
thread_id TEXT NOT NULL REFERENCES threads(thread_id),
|
||
assistant_id TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
created_at TEXT NOT NULL,
|
||
error TEXT
|
||
);
|
||
""")
|
||
_conn.commit()
|
||
|
||
|
||
# ── DB helpers ────────────────────────────────────────────────────────────────
|
||
|
||
import json # noqa: E402 (after stdlib, before third-party)
|
||
|
||
|
||
def _get_thread(thread_id: str) -> dict[str, Any] | None:
|
||
row = _conn.execute(
|
||
"SELECT thread_id, created_at, messages, values_ FROM threads WHERE thread_id = ?",
|
||
(thread_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return {
|
||
"thread_id": row["thread_id"],
|
||
"created_at": row["created_at"],
|
||
"messages": json.loads(row["messages"]),
|
||
"values": json.loads(row["values_"]),
|
||
}
|
||
|
||
|
||
def _get_run(run_id: str) -> dict[str, Any] | None:
|
||
row = _conn.execute(
|
||
"SELECT run_id, thread_id, assistant_id, status, created_at, error FROM runs WHERE run_id = ?",
|
||
(run_id,),
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return dict(row)
|
||
|
||
|
||
# ── Agent ─────────────────────────────────────────────────────────────────────
|
||
#
|
||
# Replace this with your own agent. The only requirement is that it accepts
|
||
# a messages array and returns an object with a messages array.
|
||
|
||
import os # noqa: E402
|
||
|
||
|
||
@tool
|
||
async def web_search(query: str) -> str:
|
||
"""Search the web for information. Use this to find current data, news, and analysis.
|
||
|
||
Args:
|
||
query: The search query.
|
||
"""
|
||
if os.environ.get("TAVILY_API_KEY"):
|
||
import httpx
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
res = await client.post(
|
||
"https://api.tavily.com/search",
|
||
json={"api_key": os.environ["TAVILY_API_KEY"], "query": query, "max_results": 5},
|
||
timeout=30,
|
||
)
|
||
data = res.json()
|
||
results = data.get("results") or []
|
||
if not results:
|
||
return f'No results for "{query}"'
|
||
return "\n\n".join(
|
||
f"{i + 1}. **{r['title']}**\n {r['content']}\n Source: {r['url']}"
|
||
for i, r in enumerate(results)
|
||
)
|
||
|
||
# Stub search — replace with a real search API or remove this branch.
|
||
return "\n".join([
|
||
f'[stub] Search results for "{query}":',
|
||
f"1. Key finding: Recent developments show significant progress in {query}",
|
||
f"2. Expert analysis: Industry leaders are investing heavily in {query}",
|
||
f"3. Market data: The {query} sector has seen notable activity this quarter",
|
||
])
|
||
|
||
|
||
from deepagents import create_deep_agent # noqa: E402
|
||
|
||
_agent = create_deep_agent(
|
||
model=ChatAnthropic(model="claude-sonnet-4-5"),
|
||
system_prompt=(
|
||
"You are a thorough research agent. Investigate topics using web search and produce "
|
||
"a well-structured research summary (300–500 words). Cite sources where possible.\n\n"
|
||
"If you receive new instructions mid-conversation, follow them immediately without "
|
||
"asking for clarification — discard prior work and start fresh on the new task."
|
||
),
|
||
tools=[web_search],
|
||
)
|
||
|
||
|
||
# ── Run executor ──────────────────────────────────────────────────────────────
|
||
|
||
async def _execute_run(run_id: str, thread_id: str, user_message: str) -> None:
|
||
"""Invoke the agent and persist the result; called as a fire-and-forget task."""
|
||
_conn.execute("UPDATE runs SET status = 'running' WHERE run_id = ?", (run_id,))
|
||
_conn.commit()
|
||
try:
|
||
result = await _agent.ainvoke({"messages": [HumanMessage(user_message)]})
|
||
last = result["messages"][-1]
|
||
output = last.content if isinstance(last.content, str) else json.dumps(last.content)
|
||
assistant_msg = {"role": "assistant", "content": output}
|
||
# Fetch current messages, append the assistant reply, and persist.
|
||
# values.messages is what the LangGraph SDK reads on success.
|
||
row = _conn.execute(
|
||
"SELECT messages FROM threads WHERE thread_id = ?", (thread_id,)
|
||
).fetchone()
|
||
msgs = json.loads(row[0]) if row else []
|
||
msgs.append(assistant_msg)
|
||
serialized = json.dumps(msgs)
|
||
_conn.execute(
|
||
"UPDATE threads SET messages = ?, values_ = ? WHERE thread_id = ?",
|
||
(serialized, json.dumps({"messages": msgs}), thread_id),
|
||
)
|
||
_conn.execute("UPDATE runs SET status = 'success' WHERE run_id = ?", (run_id,))
|
||
_conn.commit()
|
||
except Exception as exc: # noqa: BLE001
|
||
_conn.execute(
|
||
"UPDATE runs SET status = 'error', error = ? WHERE run_id = ?",
|
||
(str(exc), run_id),
|
||
)
|
||
_conn.commit()
|
||
|
||
|
||
# ── App ───────────────────────────────────────────────────────────────────────
|
||
|
||
@asynccontextmanager
|
||
async def _lifespan(app: FastAPI): # type: ignore[type-arg]
|
||
_init_db()
|
||
if not os.environ.get("TAVILY_API_KEY"):
|
||
print("[warn] TAVILY_API_KEY not set — using stub search. Set it for real web search.")
|
||
yield
|
||
|
||
|
||
app = FastAPI(lifespan=_lifespan)
|
||
|
||
|
||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||
|
||
@app.get("/ok")
|
||
async def health() -> dict[str, bool]:
|
||
"""Health check."""
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/threads")
|
||
async def create_thread() -> dict[str, Any]:
|
||
"""Create a thread. Called by start_async_task before creating a run."""
|
||
thread_id = str(uuid.uuid4())
|
||
now = datetime.now(UTC).isoformat()
|
||
_conn.execute(
|
||
"INSERT INTO threads (thread_id, created_at) VALUES (?, ?)",
|
||
(thread_id, now),
|
||
)
|
||
_conn.commit()
|
||
return {"thread_id": thread_id, "created_at": now, "messages": [], "values": {}}
|
||
|
||
|
||
@app.post("/threads/{thread_id}/runs")
|
||
async def create_run(thread_id: str, request: Request) -> dict[str, Any]:
|
||
"""Create a run on an existing thread.
|
||
|
||
Called by both start_async_task (new task) and update_async_task
|
||
(re-run with new instructions). When multitask_strategy is 'interrupt',
|
||
any currently-running runs on the thread are cancelled and the thread
|
||
state is cleared before the new run starts.
|
||
"""
|
||
thread = _get_thread(thread_id)
|
||
if thread is None:
|
||
raise HTTPException(status_code=404, detail="Thread not found")
|
||
|
||
body = await request.json()
|
||
multitask_strategy = body.get("multitask_strategy")
|
||
|
||
if multitask_strategy == "interrupt":
|
||
_conn.execute(
|
||
"UPDATE runs SET status = 'cancelled' WHERE thread_id = ? AND status = 'running'",
|
||
(thread_id,),
|
||
)
|
||
_conn.execute(
|
||
"UPDATE threads SET values_ = '{}' WHERE thread_id = ?",
|
||
(thread_id,),
|
||
)
|
||
_conn.commit()
|
||
|
||
messages = (body.get("input") or {}).get("messages") or []
|
||
user_message = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||
|
||
if user_message:
|
||
existing = json.loads(
|
||
_conn.execute(
|
||
"SELECT messages FROM threads WHERE thread_id = ?", (thread_id,)
|
||
).fetchone()[0]
|
||
)
|
||
existing.append({"role": "user", "content": user_message})
|
||
_conn.execute(
|
||
"UPDATE threads SET messages = ? WHERE thread_id = ?",
|
||
(json.dumps(existing), thread_id),
|
||
)
|
||
_conn.commit()
|
||
|
||
run_id = str(uuid.uuid4())
|
||
now = datetime.now(UTC).isoformat()
|
||
assistant_id = body.get("assistant_id") or "researcher"
|
||
_conn.execute(
|
||
"INSERT INTO runs (run_id, thread_id, assistant_id, created_at) VALUES (?, ?, ?, ?)",
|
||
(run_id, thread_id, assistant_id, now),
|
||
)
|
||
_conn.commit()
|
||
|
||
# Fire and forget — client polls GET /threads/{thread_id}/runs/{run_id} for status.
|
||
asyncio.ensure_future(_execute_run(run_id, thread_id, user_message))
|
||
|
||
return {
|
||
"run_id": run_id,
|
||
"thread_id": thread_id,
|
||
"assistant_id": assistant_id,
|
||
"status": "pending",
|
||
"created_at": now,
|
||
"error": None,
|
||
}
|
||
|
||
|
||
@app.get("/threads/{thread_id}/runs/{run_id}")
|
||
async def get_run(thread_id: str, run_id: str) -> dict[str, Any]:
|
||
"""Get run status. Called by check_async_task to poll whether a task has finished."""
|
||
run = _get_run(run_id)
|
||
if run is None or run["thread_id"] != thread_id:
|
||
raise HTTPException(status_code=404, detail="Run not found")
|
||
return run
|
||
|
||
|
||
@app.get("/threads/{thread_id}")
|
||
async def get_thread(thread_id: str) -> dict[str, Any]:
|
||
"""Get thread state. Called by check_async_task after a run reaches 'success' status.
|
||
|
||
The SDK reads values['messages'] to extract the final result.
|
||
"""
|
||
thread = _get_thread(thread_id)
|
||
if thread is None:
|
||
raise HTTPException(status_code=404, detail="Thread not found")
|
||
return thread
|
||
|
||
|
||
@app.post("/threads/{thread_id}/runs/{run_id}/cancel")
|
||
async def cancel_run(thread_id: str, run_id: str) -> dict[str, Any]:
|
||
"""Cancel a run. Called by cancel_async_task.
|
||
|
||
Marks the run cancelled in the database. Note: the agent invocation is not
|
||
interrupted mid-flight — for true cancellation wire in asyncio.Task cancellation.
|
||
"""
|
||
run = _get_run(run_id)
|
||
if run is None or run["thread_id"] != thread_id:
|
||
raise HTTPException(status_code=404, detail="Run not found")
|
||
_conn.execute("UPDATE runs SET status = 'cancelled' WHERE run_id = ?", (run_id,))
|
||
_conn.commit()
|
||
return {**run, "status": "cancelled"}
|