|
| 1 | +"""Research assistant — a copy-paste starting point for building with |
| 2 | +OpenClaw Managed Agents from Python. |
| 3 | +
|
| 4 | +What it does: |
| 5 | + 1. Connects to a running orchestrator (default http://localhost:8080). |
| 6 | + 2. Creates an agent template tuned for research-flavored answers. |
| 7 | + 3. Opens a session. |
| 8 | + 4. Accepts questions at the terminal. For each question, posts a |
| 9 | + user message and streams events in real time — thinking blocks, |
| 10 | + tool calls, tool results, and the final agent message — with a |
| 11 | + compact human-readable format. Polls the session state at end of |
| 12 | + turn to report rolling token + cost usage. |
| 13 | + 5. Repeats until the user types `/quit`. |
| 14 | +
|
| 15 | +Run: |
| 16 | + pip install openclaw-managed-agents>=0.2.0 |
| 17 | + export MOONSHOT_API_KEY=sk-... # or any provider |
| 18 | + docker compose up -d # in the repo root |
| 19 | + python research_assistant.py |
| 20 | +
|
| 21 | +Optional env vars: |
| 22 | + OPENCLAW_ORCHESTRATOR_URL default http://localhost:8080 |
| 23 | + OPENCLAW_API_TOKEN bearer token (match the orchestrator's) |
| 24 | + OPENCLAW_MODEL default moonshot/kimi-k2.5 |
| 25 | +""" |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import os |
| 29 | +import sys |
| 30 | +from typing import Optional |
| 31 | + |
| 32 | +from openclaw_managed_agents import OpenClawClient |
| 33 | + |
| 34 | +# ANSI colors. Plain ASCII if stdout isn't a TTY (piping to a file etc.). |
| 35 | +_ANSI = sys.stdout.isatty() |
| 36 | + |
| 37 | + |
| 38 | +def _color(code: str, s: str) -> str: |
| 39 | + return f"\x1b[{code}m{s}\x1b[0m" if _ANSI else s |
| 40 | + |
| 41 | + |
| 42 | +DIM = lambda s: _color("2", s) # noqa: E731 |
| 43 | +CYAN = lambda s: _color("36", s) # noqa: E731 |
| 44 | +GREEN = lambda s: _color("32", s) # noqa: E731 |
| 45 | +YELLOW = lambda s: _color("33", s) # noqa: E731 |
| 46 | +MAGENTA = lambda s: _color("35", s) # noqa: E731 |
| 47 | +RED = lambda s: _color("31", s) # noqa: E731 |
| 48 | + |
| 49 | + |
| 50 | +RESEARCH_INSTRUCTIONS = ( |
| 51 | + "You are a research assistant. When answering, cite sources " |
| 52 | + "whenever possible, label any uncertainty explicitly, and prefer " |
| 53 | + "structured summaries over long prose. If a question needs " |
| 54 | + "verification, use your tools to look it up; never fabricate." |
| 55 | +) |
| 56 | + |
| 57 | + |
| 58 | +def print_event(event) -> None: |
| 59 | + """Format a single streamed event to stdout.""" |
| 60 | + t = event.type |
| 61 | + if t == "user.message": |
| 62 | + # Echo user messages we've posted — skipped here because we |
| 63 | + # already print them when the user types. |
| 64 | + return |
| 65 | + if t == "agent.thinking": |
| 66 | + print(DIM(f" … thinking: {event.content}")) |
| 67 | + return |
| 68 | + if t == "agent.tool_use": |
| 69 | + args = event.tool_arguments or {} |
| 70 | + # Keep it compact — one line per tool call. |
| 71 | + args_preview = ", ".join(f"{k}={v!r}" for k, v in list(args.items())[:3]) |
| 72 | + if len(args) > 3: |
| 73 | + args_preview += ", …" |
| 74 | + print(MAGENTA(f" → tool {event.tool_name}({args_preview})")) |
| 75 | + return |
| 76 | + if t == "agent.tool_result": |
| 77 | + prefix = RED(" ← tool_err ") if event.is_error else CYAN(" ← tool_out ") |
| 78 | + snippet = event.content.strip().splitlines()[0] if event.content else "(empty)" |
| 79 | + if len(snippet) > 120: |
| 80 | + snippet = snippet[:117] + "…" |
| 81 | + print(f"{prefix}{snippet}") |
| 82 | + return |
| 83 | + if t == "agent.message": |
| 84 | + print() |
| 85 | + print(GREEN("assistant:")) |
| 86 | + print(event.content) |
| 87 | + # Per-turn usage surfaces here when the model reports it. |
| 88 | + if event.tokens or event.cost_usd is not None: |
| 89 | + usage_bits = [] |
| 90 | + if event.tokens: |
| 91 | + usage_bits.append( |
| 92 | + f"{event.tokens.get('input', 0)} in / " |
| 93 | + f"{event.tokens.get('output', 0)} out" |
| 94 | + ) |
| 95 | + if event.cost_usd is not None: |
| 96 | + usage_bits.append(f"${event.cost_usd:.4f}") |
| 97 | + print(DIM(f" [turn usage: {', '.join(usage_bits)}]")) |
| 98 | + return |
| 99 | + if t == "agent.tool_confirmation_request": |
| 100 | + # always_ask policy — not used in this example's agent template, |
| 101 | + # but surface it if it shows up so the reader knows the hook exists. |
| 102 | + print(YELLOW(f" ⚠ tool confirmation requested: {event.tool_name} " |
| 103 | + f"(respond via client.sessions.confirm_tool(...))")) |
| 104 | + return |
| 105 | + if t == "agent.error": |
| 106 | + print(RED(f" ⚠ error: {event.content}")) |
| 107 | + return |
| 108 | + # session.model_change / session.thinking_level_change / session.compaction |
| 109 | + if t.startswith("session."): |
| 110 | + print(DIM(f" [{t}: {event.content}]")) |
| 111 | + |
| 112 | + |
| 113 | +def run_turn(client: OpenClawClient, session_id: str, prompt: str) -> None: |
| 114 | + print(CYAN("you: ") + prompt) |
| 115 | + client.sessions.send(session_id, content=prompt) |
| 116 | + # stream() returns an iterator over events; it blocks on the SSE |
| 117 | + # connection and ends when the session goes idle for ~30 s after |
| 118 | + # the last event. We break out as soon as the first agent.message |
| 119 | + # lands for this turn — the demo-grade UX we want. |
| 120 | + seen_agent_msg_ids: set[str] = set() |
| 121 | + # Pre-populate with any agent.messages from earlier turns so we |
| 122 | + # don't stop on a stale one during catch-up. |
| 123 | + for prior in client.sessions.events(session_id): |
| 124 | + if prior.type == "agent.message": |
| 125 | + seen_agent_msg_ids.add(prior.event_id) |
| 126 | + for event in client.sessions.stream(session_id): |
| 127 | + print_event(event) |
| 128 | + if event.type == "agent.message" and event.event_id not in seen_agent_msg_ids: |
| 129 | + break |
| 130 | + |
| 131 | + |
| 132 | +def main() -> int: |
| 133 | + base_url = os.environ.get("OPENCLAW_ORCHESTRATOR_URL", "http://localhost:8080") |
| 134 | + api_token = os.environ.get("OPENCLAW_API_TOKEN") or None |
| 135 | + model = os.environ.get("OPENCLAW_MODEL", "moonshot/kimi-k2.5") |
| 136 | + |
| 137 | + client = OpenClawClient(base_url=base_url, api_token=api_token) |
| 138 | + |
| 139 | + # Verify the orchestrator is reachable before creating anything. |
| 140 | + # Matches the deploy scripts' /healthz probe pattern. |
| 141 | + import httpx |
| 142 | + try: |
| 143 | + resp = httpx.get(f"{base_url}/healthz", timeout=5.0) |
| 144 | + resp.raise_for_status() |
| 145 | + except httpx.HTTPError as err: |
| 146 | + print(RED(f"cannot reach {base_url}/healthz: {err}"), file=sys.stderr) |
| 147 | + print( |
| 148 | + " — is the orchestrator running? Try: docker compose up -d", |
| 149 | + file=sys.stderr, |
| 150 | + ) |
| 151 | + return 1 |
| 152 | + |
| 153 | + print(DIM(f"connected to {base_url}")) |
| 154 | + print(DIM(f"model: {model}")) |
| 155 | + if api_token: |
| 156 | + print(DIM("auth: bearer-token (OPENCLAW_API_TOKEN set)")) |
| 157 | + else: |
| 158 | + print(DIM("auth: disabled")) |
| 159 | + |
| 160 | + agent = client.agents.create( |
| 161 | + model=model, |
| 162 | + instructions=RESEARCH_INSTRUCTIONS, |
| 163 | + # tools defaults to [] — the agent uses whatever skills the |
| 164 | + # runtime image has pre-installed. Point this at specific skill |
| 165 | + # ids once the runtime exposes them explicitly. |
| 166 | + ) |
| 167 | + print(DIM(f"agent: {agent.agent_id}")) |
| 168 | + |
| 169 | + session = client.sessions.create(agent_id=agent.agent_id) |
| 170 | + print(DIM(f"session: {session.session_id}")) |
| 171 | + print(DIM("type /quit to exit, /usage for cumulative token + cost")) |
| 172 | + print() |
| 173 | + |
| 174 | + try: |
| 175 | + while True: |
| 176 | + try: |
| 177 | + prompt = input(CYAN("> ")) |
| 178 | + except (EOFError, KeyboardInterrupt): |
| 179 | + print() |
| 180 | + break |
| 181 | + prompt = prompt.strip() |
| 182 | + if not prompt: |
| 183 | + continue |
| 184 | + if prompt == "/quit": |
| 185 | + break |
| 186 | + if prompt == "/usage": |
| 187 | + current = client.sessions.get(session.session_id) |
| 188 | + print( |
| 189 | + DIM( |
| 190 | + f"cumulative: " |
| 191 | + f"{current.tokens['input']} in / " |
| 192 | + f"{current.tokens['output']} out, " |
| 193 | + f"${current.cost_usd:.4f}" |
| 194 | + ) |
| 195 | + ) |
| 196 | + continue |
| 197 | + run_turn(client, session.session_id, prompt) |
| 198 | + print() |
| 199 | + finally: |
| 200 | + # Leave the session alive for post-hoc inspection via |
| 201 | + # GET /v1/sessions/<id>/events. Uncomment to tear down. |
| 202 | + # client.sessions.delete(session.session_id) |
| 203 | + client.close() |
| 204 | + |
| 205 | + return 0 |
| 206 | + |
| 207 | + |
| 208 | +if __name__ == "__main__": |
| 209 | + raise SystemExit(main()) |
0 commit comments