From fe59781ac2d5cfef136338006e61802fa1dca683 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 18:57:39 -0700 Subject: [PATCH 01/17] harness --- fleet_harness.py | 326 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 fleet_harness.py diff --git a/fleet_harness.py b/fleet_harness.py new file mode 100644 index 00000000..d3ba5c02 --- /dev/null +++ b/fleet_harness.py @@ -0,0 +1,326 @@ +import ast +import asyncio +import json +from datetime import datetime +from typing import Any, List, Optional + +import fleet +from dotenv import load_dotenv +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.types import Tool +from anthropic import AsyncAnthropic +from anthropic.types import ( + MessageParam, + TextBlockParam, + ToolParam, + ToolResultBlockParam, +) + +load_dotenv() + + +client = AsyncAnthropic() + + +MODEL = "claude-opus-4-7" + + +def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"{prefix}_{timestamp}.{extension}" + filepath = f"/tmp/{filename}" + + with open(filepath, "w") as f: + f.write(content) + + return filepath + + +def convert_tool_format(tool: Tool) -> ToolParam: + return { + "name": tool.name, + "description": tool.description or "", + "input_schema": tool.inputSchema, + } + + +def _block_field(block: Any, key: str) -> Any: + if isinstance(block, dict): + return block.get(key) + return getattr(block, key, None) + + +def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: + """Return True if the verifier's top-level function declares a `conversation` param. + + The verifier is a Python source string (e.g. `def verify(env, final_answer=None, + conversation=None): ...`). We parse it with ast and inspect the first top-level + function's signature. Nested helper functions are intentionally ignored. + """ + if not verifier_func: + return False + try: + tree = ast.parse(verifier_func) + except SyntaxError: + return False + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + args = node.args + param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} + return "conversation" in param_names + return False + + +def to_openai_conversation( + system: List[TextBlockParam], + messages: List[MessageParam], +) -> List[dict]: + """Convert Anthropic-format system+messages into OpenAI chat schema. + + This is the format Fleet verifiers expect for the `conversation` param: + - {"role": "system", "content": str} + - {"role": "user", "content": str} + - {"role": "assistant", "content": str | None, "tool_calls": [...]} + - {"role": "tool", "tool_call_id": str, "content": str} + """ + out: List[dict] = [] + + system_text = "\n\n".join( + b["text"] for b in system if b.get("type") == "text" and b.get("text") + ) + if system_text: + out.append({"role": "system", "content": system_text}) + + for msg in messages: + role = msg["role"] + content = msg["content"] + blocks = [content] if isinstance(content, str) else list(content) + + if role == "user": + text_parts: List[str] = [] + tool_msgs: List[dict] = [] + for block in blocks: + if isinstance(block, str): + text_parts.append(block) + continue + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_result": + tool_content = _block_field(block, "content") + if not isinstance(tool_content, str): + tool_content = json.dumps(tool_content, default=str) + tool_msgs.append( + { + "role": "tool", + "tool_call_id": _block_field(block, "tool_use_id"), + "content": tool_content, + } + ) + + if text_parts: + out.append({"role": "user", "content": "\n".join(text_parts)}) + out.extend(tool_msgs) + continue + + if role == "assistant": + text_parts = [] + tool_calls: List[dict] = [] + for block in blocks: + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_use": + tool_calls.append( + { + "id": _block_field(block, "id"), + "type": "function", + "function": { + "name": _block_field(block, "name"), + "arguments": json.dumps( + _block_field(block, "input") or {} + ), + }, + } + ) + + entry: dict = { + "role": "assistant", + "content": "\n".join(text_parts) if text_parts else None, + } + if tool_calls: + entry["tool_calls"] = tool_calls + out.append(entry) + + return out + + +async def wait_for_mcp( + mcp_url: str, timeout: float = 120.0, delay: float = 1.0 +) -> None: + deadline = asyncio.get_event_loop().time() + timeout + attempt = 0 + last_error: BaseException | None = None + while asyncio.get_event_loop().time() < deadline: + attempt += 1 + try: + async with streamable_http_client(mcp_url) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + print(f"\rMCP ready after {attempt} attempt(s) ") + return + except BaseException as e: + last_error = e + err_name = type(e).__name__ + print( + f"\rWaiting for MCP (attempt {attempt}, {err_name})...", + end="", + flush=True, + ) + await asyncio.sleep(delay) + raise TimeoutError( + f"MCP did not become ready at {mcp_url} within {timeout}s (last error: {last_error})" + ) + + +async def main(): + tasks = await fleet.load_tasks_async(project_key="bloomberg-sample-tasks") + task = tasks[0] + + print("Task Key:", task.key) + print("Task Prompt:", task.prompt) + + env = await fleet.env.make_async( + env_key=task.env_key, + data_key=task.data_key, + env_variables=task.env_variables, + ttl_seconds=3600, + ) + print("Instance URL:", env.urls.root) + mcp_url = env.mcp.url + + await wait_for_mcp(mcp_url) + + print(f"App URL: {env.urls.app[0]}") + + system: List[TextBlockParam] = [ + { + "type": "text", + "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", + "cache_control": {"type": "ephemeral"}, + } + ] + messages: List[MessageParam] = [ + { + "role": "user", + "content": [{"type": "text", "text": task.prompt}], + } + ] + + async with streamable_http_client(mcp_url) as (read_stream, write_stream, _): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + list_tools = await session.list_tools() + + anthropic_tools: List[ToolParam] = [ + convert_tool_format(tool) for tool in list_tools.tools + ] + if anthropic_tools: + anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} + + print(f"Loaded {len(anthropic_tools)} tools") + + while True: + print(f"\nSending {len(messages)} messages") + print([m["role"] for m in messages]) + + messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} + + print("\nAssistant: ", end="", flush=True) + async with client.messages.stream( + model=MODEL, + max_tokens=128000, + messages=messages, + tools=anthropic_tools, + system=system, + ) as stream: + async for text in stream.text_stream: + print(text, end="", flush=True) + response = await stream.get_final_message() + print() + + del messages[-1]["content"][-1]["cache_control"] + + usage = response.usage + print(f"Stop reason: {response.stop_reason}") + print( + f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " + f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " + f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_results: List[ToolResultBlockParam] = [] + for block in response.content: + if block.type != "tool_use": + continue + + print(f"\nTool ({block.name}): {block.input}") + + result = await session.call_tool(block.name, block.input) + result_str = result.content[0].text + + result_path = save_to_tmp( + result_str, prefix="tool_result", extension="txt" + ) + print(f"Tool result saved to: {result_path}") + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result_str, + } + ) + + if not tool_results: + break + + messages.append({"role": "user", "content": tool_results}) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + transcript_filename = f"messages_transcript_{timestamp}.json" + transcript_path = f"/tmp/{transcript_filename}" + + with open(transcript_path, "w") as f: + json.dump(messages, f, indent=2, default=str) + + print(f"\nFull transcript saved to: {transcript_path}") + + final_answer = response.content[-1].text + print(f" Final Answer: {final_answer}") + + verify_kwargs: dict = {"final_answer": final_answer} + if verifier_accepts_conversation(task.verifier_func): + verify_kwargs["conversation"] = to_openai_conversation(system, messages) + print("Verifier accepts `conversation` param; passing it.") + else: + print("Verifier does not accept `conversation` param; skipping.") + + result = await task.verify_detailed_async(env, **verify_kwargs) + print(f"Verifier stdout:", result.stdout) + print(f"Reward score:", result.result) + + await env.close() + + +if __name__ == "__main__": + asyncio.run(main()) From d490393c63d702cfecdf61d6d3c09d03a3e45612 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 19:02:42 -0700 Subject: [PATCH 02/17] multi_env_listp --- fleet/_async/models.py | 2 ++ fleet/models.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/fleet/_async/models.py b/fleet/_async/models.py index 2159b932..35aec741 100644 --- a/fleet/_async/models.py +++ b/fleet/_async/models.py @@ -51,6 +51,7 @@ class Instance(BaseModel): team_id: str = Field(..., title="Team Id") region: str = Field(..., title="Region") env_variables: Optional[Dict[str, Any]] = Field(None, title="Env Variables") + multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List") class InstanceRequest(BaseModel): @@ -357,6 +358,7 @@ class InstanceResponse(BaseModel): data_version: Optional[str] = Field(None, title="Data Version") urls: Optional[InstanceURLs] = Field(None, title="Urls") health: Optional[bool] = Field(None, title="Health") + multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List") class AccountResponse(BaseModel): diff --git a/fleet/models.py b/fleet/models.py index 30439f33..ccf5a0a5 100644 --- a/fleet/models.py +++ b/fleet/models.py @@ -52,6 +52,7 @@ class Instance(BaseModel): region: str = Field(..., title="Region") env_variables: Optional[Dict[str, Any]] = Field(None, title="Env Variables") run_id: Optional[str] = Field(None, title="Run Id") + multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List") class InstanceRequest(BaseModel): @@ -369,6 +370,7 @@ class InstanceResponse(BaseModel): profile_id: Optional[str] = Field(None, title="Profile Id") heartbeat_interval: Optional[int] = Field(None, title="Heartbeat Interval") heartbeat_region: Optional[str] = Field(None, title="Heartbeat Region") + multi_env_list: Optional[List[str]] = Field(None, title="Multi Env List") class Run(BaseModel): From 806aac1dfc68f866fdb08b1d788f5ef96a030078 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 19:16:02 -0700 Subject: [PATCH 03/17] harness --- fleet_harness.py | 173 ++++++++++++++++++++++++++++------------------- 1 file changed, 102 insertions(+), 71 deletions(-) diff --git a/fleet_harness.py b/fleet_harness.py index d3ba5c02..905e3595 100644 --- a/fleet_harness.py +++ b/fleet_harness.py @@ -1,6 +1,7 @@ import ast import asyncio import json +from contextlib import AsyncExitStack from datetime import datetime from typing import Any, List, Optional @@ -203,9 +204,19 @@ async def main(): ttl_seconds=3600, ) print("Instance URL:", env.urls.root) - mcp_url = env.mcp.url - await wait_for_mcp(mcp_url) + if env.multi_env_list: + endpoints = [(app, f"{env.urls.root}{app}/mcp") for app in env.multi_env_list] + else: + endpoints = [(None, env.mcp.url)] + + print(f"MCP endpoints ({len(endpoints)}):") + for app_name, url in endpoints: + print(f" {app_name or '(root)'}: {url}") + + print(f"\nProbing {len(endpoints)} endpoint(s) in parallel...") + await asyncio.gather(*(wait_for_mcp(url) for _, url in endpoints)) + print(f"All {len(endpoints)} MCP endpoint(s) ready") print(f"App URL: {env.urls.app[0]}") @@ -223,87 +234,107 @@ async def main(): } ] - async with streamable_http_client(mcp_url) as (read_stream, write_stream, _): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() + async with AsyncExitStack() as stack: + anthropic_tools: List[ToolParam] = [] + # namespaced tool name -> (session, original mcp tool name) + dispatch: dict = {} - list_tools = await session.list_tools() - - anthropic_tools: List[ToolParam] = [ - convert_tool_format(tool) for tool in list_tools.tools - ] - if anthropic_tools: - anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} - - print(f"Loaded {len(anthropic_tools)} tools") - - while True: - print(f"\nSending {len(messages)} messages") - print([m["role"] for m in messages]) - - messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} - - print("\nAssistant: ", end="", flush=True) - async with client.messages.stream( - model=MODEL, - max_tokens=128000, - messages=messages, - tools=anthropic_tools, - system=system, - ) as stream: - async for text in stream.text_stream: - print(text, end="", flush=True) - response = await stream.get_final_message() - print() - - del messages[-1]["content"][-1]["cache_control"] - - usage = response.usage - print(f"Stop reason: {response.stop_reason}") - print( - f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " - f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " - f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" - ) + for app_name, url in endpoints: + read_stream, write_stream, _ = await stack.enter_async_context( + streamable_http_client(url) + ) + session = await stack.enter_async_context( + ClientSession(read_stream, write_stream) + ) + await session.initialize() - messages.append({"role": "assistant", "content": response.content}) + tools_resp = await session.list_tools() + # Hyphens in app names (e.g. "google-maps") aren't valid in OpenAI/Anthropic + # function names, so swap them for underscores. + prefix = f"{app_name.replace('-', '_')}__" if app_name else "" + + for mcp_tool in tools_resp.tools: + tool_param = convert_tool_format(mcp_tool) + if prefix: + tool_param["name"] = f"{prefix}{mcp_tool.name}" + anthropic_tools.append(tool_param) + dispatch[tool_param["name"]] = (session, mcp_tool.name) + + if anthropic_tools: + anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} + + print( + f"Loaded {len(anthropic_tools)} tools across {len(endpoints)} MCP endpoint(s)" + ) + + while True: + print(f"\nSending {len(messages)} messages") + print([m["role"] for m in messages]) + + messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} + + print("\nAssistant: ", end="", flush=True) + async with client.messages.stream( + model=MODEL, + max_tokens=128000, + messages=messages, + tools=anthropic_tools, + system=system, + ) as stream: + async for text in stream.text_stream: + print(text, end="", flush=True) + response = await stream.get_final_message() + print() + + del messages[-1]["content"][-1]["cache_control"] + + usage = response.usage + print(f"Stop reason: {response.stop_reason}") + print( + f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " + f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " + f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" + ) - tool_results: List[ToolResultBlockParam] = [] - for block in response.content: - if block.type != "tool_use": - continue + messages.append({"role": "assistant", "content": response.content}) - print(f"\nTool ({block.name}): {block.input}") + tool_results: List[ToolResultBlockParam] = [] + for block in response.content: + if block.type != "tool_use": + continue - result = await session.call_tool(block.name, block.input) - result_str = result.content[0].text + print(f"\nTool ({block.name}): {block.input}") - result_path = save_to_tmp( - result_str, prefix="tool_result", extension="txt" - ) - print(f"Tool result saved to: {result_path}") + target_session, original_name = dispatch[block.name] + result = await target_session.call_tool(original_name, block.input) + result_str = result.content[0].text - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": result_str, - } - ) + result_path = save_to_tmp( + result_str, prefix="tool_result", extension="txt" + ) + print(f"Tool result saved to: {result_path}") + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result_str, + } + ) - if not tool_results: - break + if not tool_results: + break - messages.append({"role": "user", "content": tool_results}) + messages.append({"role": "user", "content": tool_results}) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - transcript_filename = f"messages_transcript_{timestamp}.json" - transcript_path = f"/tmp/{transcript_filename}" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + transcript_filename = f"messages_transcript_{timestamp}.json" + transcript_path = f"/tmp/{transcript_filename}" - with open(transcript_path, "w") as f: - json.dump(messages, f, indent=2, default=str) + with open(transcript_path, "w") as f: + json.dump(messages, f, indent=2, default=str) - print(f"\nFull transcript saved to: {transcript_path}") + print(f"\nFull transcript saved to: {transcript_path}") final_answer = response.content[-1].text print(f" Final Answer: {final_answer}") From ebf6cfa79c4897143c7dc57cec049bf578c434ef Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 19:44:24 -0700 Subject: [PATCH 04/17] client --- fleet/_async/client.py | 6 ++++-- fleet/client.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/fleet/_async/client.py b/fleet/_async/client.py index 5e140b90..6a07f3e6 100644 --- a/fleet/_async/client.py +++ b/fleet/_async/client.py @@ -601,7 +601,9 @@ async def make( ) instance = AsyncEnv(client=self.client, **response.json()) - await instance.instance.load() + # Resources are loaded lazily on first `db()`/`browser()`/`resources()` access + # via `_load_resources()`, so we don't preload here. Eagerly loading would + # fail-fast with a 502 while the container is still warming up. return instance async def make_for_task(self, task: Task) -> AsyncEnv: @@ -653,7 +655,7 @@ async def instance(self, instance_id: Union[str, Dict[str, str]]) -> AsyncEnv: else: response = await self.client.request("GET", f"/v1/env/instances/{instance_id}") instance = AsyncEnv(client=self.client, **response.json()) - await instance.instance.load() + # Resources load lazily on first `db()`/`browser()`/`resources()` access. return instance def _create_url_instance(self, base_url: str) -> AsyncEnv: diff --git a/fleet/client.py b/fleet/client.py index 276d1b93..1db3e748 100644 --- a/fleet/client.py +++ b/fleet/client.py @@ -613,7 +613,9 @@ def make( ) instance = SyncEnv(client=self.client, **response.json()) - instance.instance.load() + # Resources load lazily on first `db()`/`browser()`/`resources()` access via + # `_load_resources()`. Skipping the eager preload avoids fail-fast 502s while + # the container is still warming up. return instance def make_for_task(self, task: Task) -> SyncEnv: @@ -665,7 +667,7 @@ def instance(self, instance_id: Union[str, Dict[str, str]]) -> SyncEnv: else: response = self.client.request("GET", f"/v1/env/instances/{instance_id}") instance = SyncEnv(client=self.client, **response.json()) - instance.instance.load() + # Resources load lazily on first `db()`/`browser()`/`resources()` access. return instance def _create_url_instance(self, base_url: str) -> SyncEnv: From 3f1d0247113f32b723041fb372c6196a9d1186b4 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 19:56:06 -0700 Subject: [PATCH 05/17] fixes --- claw_harness.py | 366 +++++++++++++++++++++++++++++++++++++++++++++++ fleet_harness.py | 11 +- 2 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 claw_harness.py diff --git a/claw_harness.py b/claw_harness.py new file mode 100644 index 00000000..d7b998fa --- /dev/null +++ b/claw_harness.py @@ -0,0 +1,366 @@ +import ast +import asyncio +import json +from contextlib import AsyncExitStack +from datetime import datetime +from typing import Any, List, Optional + +import fleet +from dotenv import load_dotenv +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.types import Tool +from anthropic import AsyncAnthropic +from anthropic.types import ( + MessageParam, + TextBlockParam, + ToolParam, + ToolResultBlockParam, +) + +load_dotenv() + + +client = AsyncAnthropic() + + +MODEL = "claude-opus-4-7" + + +def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"{prefix}_{timestamp}.{extension}" + filepath = f"/tmp/{filename}" + + with open(filepath, "w") as f: + f.write(content) + + return filepath + + +def convert_tool_format(tool: Tool) -> ToolParam: + return { + "name": tool.name, + "description": tool.description or "", + "input_schema": tool.inputSchema, + } + + +def _block_field(block: Any, key: str) -> Any: + if isinstance(block, dict): + return block.get(key) + return getattr(block, key, None) + + +def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: + """Return True if the verifier's top-level function declares a `conversation` param. + + The verifier is a Python source string (e.g. `def verify(env, final_answer=None, + conversation=None): ...`). We parse it with ast and inspect the first top-level + function's signature. Nested helper functions are intentionally ignored. + """ + if not verifier_func: + return False + try: + tree = ast.parse(verifier_func) + except SyntaxError: + return False + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + args = node.args + param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} + return "conversation" in param_names + return False + + +def to_openai_conversation( + system: List[TextBlockParam], + messages: List[MessageParam], +) -> List[dict]: + """Convert Anthropic-format system+messages into OpenAI chat schema. + + This is the format Fleet verifiers expect for the `conversation` param: + - {"role": "system", "content": str} + - {"role": "user", "content": str} + - {"role": "assistant", "content": str | None, "tool_calls": [...]} + - {"role": "tool", "tool_call_id": str, "content": str} + """ + out: List[dict] = [] + + system_text = "\n\n".join( + b["text"] for b in system if b.get("type") == "text" and b.get("text") + ) + if system_text: + out.append({"role": "system", "content": system_text}) + + for msg in messages: + role = msg["role"] + content = msg["content"] + blocks = [content] if isinstance(content, str) else list(content) + + if role == "user": + text_parts: List[str] = [] + tool_msgs: List[dict] = [] + for block in blocks: + if isinstance(block, str): + text_parts.append(block) + continue + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_result": + tool_content = _block_field(block, "content") + if not isinstance(tool_content, str): + tool_content = json.dumps(tool_content, default=str) + tool_msgs.append( + { + "role": "tool", + "tool_call_id": _block_field(block, "tool_use_id"), + "content": tool_content, + } + ) + + if text_parts: + out.append({"role": "user", "content": "\n".join(text_parts)}) + out.extend(tool_msgs) + continue + + if role == "assistant": + text_parts = [] + tool_calls: List[dict] = [] + for block in blocks: + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_use": + tool_calls.append( + { + "id": _block_field(block, "id"), + "type": "function", + "function": { + "name": _block_field(block, "name"), + "arguments": json.dumps( + _block_field(block, "input") or {} + ), + }, + } + ) + + entry: dict = { + "role": "assistant", + "content": "\n".join(text_parts) if text_parts else None, + } + if tool_calls: + entry["tool_calls"] = tool_calls + out.append(entry) + + return out + + +async def wait_for_mcp( + mcp_url: str, timeout: float = 120.0, delay: float = 1.0 +) -> None: + deadline = asyncio.get_event_loop().time() + timeout + attempt = 0 + last_error: BaseException | None = None + while asyncio.get_event_loop().time() < deadline: + attempt += 1 + try: + async with streamable_http_client(mcp_url) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + print(f"\rMCP ready after {attempt} attempt(s) ") + return + except BaseException as e: + last_error = e + err_name = type(e).__name__ + print( + f"\rWaiting for MCP (attempt {attempt}, {err_name})...", + end="", + flush=True, + ) + await asyncio.sleep(delay) + raise TimeoutError( + f"MCP did not become ready at {mcp_url} within {timeout}s (last error: {last_error})" + ) + + +async def main(): + tasks = await fleet.load_tasks_async( + keys=["task_x4zukk7uk6sj_n_1776210959854_ixm2x50h2_bash"] + ) + task = tasks[0] + + print("Task Key:", task.key) + print("Task Prompt:", task.prompt) + + env = await fleet.env.make_async( + env_key=task.env_key, + data_key=task.data_key, + env_variables=task.env_variables, + ttl_seconds=3600, + ) + print("Instance URL:", env.urls.root) + + if env.multi_env_list: + endpoints = [(app, f"{env.urls.root}{app}/mcp") for app in env.multi_env_list] + else: + endpoints = [(None, env.mcp.url)] + + print(f"MCP endpoints ({len(endpoints)}):") + for app_name, url in endpoints: + print(f" {app_name or '(root)'}: {url}") + + print(f"\nProbing {len(endpoints)} endpoint(s) in parallel...") + await asyncio.gather(*(wait_for_mcp(url) for _, url in endpoints)) + print(f"All {len(endpoints)} MCP endpoint(s) ready") + + print(f"App URL: {env.urls.app[0]}") + + system: List[TextBlockParam] = [ + { + "type": "text", + "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", + "cache_control": {"type": "ephemeral"}, + } + ] + messages: List[MessageParam] = [ + { + "role": "user", + "content": [{"type": "text", "text": task.prompt}], + } + ] + + async with AsyncExitStack() as stack: + anthropic_tools: List[ToolParam] = [] + # namespaced tool name -> (session, original mcp tool name) + dispatch: dict = {} + + for app_name, url in endpoints: + read_stream, write_stream, _ = await stack.enter_async_context( + streamable_http_client(url) + ) + session = await stack.enter_async_context( + ClientSession(read_stream, write_stream) + ) + await session.initialize() + + tools_resp = await session.list_tools() + # Hyphens in app names (e.g. "google-maps") aren't valid in OpenAI/Anthropic + # function names, so swap them for underscores. + prefix = f"{app_name.replace('-', '_')}__" if app_name else "" + + for mcp_tool in tools_resp.tools: + tool_param = convert_tool_format(mcp_tool) + if prefix: + tool_param["name"] = f"{prefix}{mcp_tool.name}" + anthropic_tools.append(tool_param) + dispatch[tool_param["name"]] = (session, mcp_tool.name) + + if anthropic_tools: + anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} + + print( + f"Loaded {len(anthropic_tools)} tools across {len(endpoints)} MCP endpoint(s)" + ) + + while True: + print(f"\nSending {len(messages)} messages") + print([m["role"] for m in messages]) + + messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} + + print("\nAssistant: ", end="", flush=True) + async with client.messages.stream( + model=MODEL, + max_tokens=128000, + messages=messages, + tools=anthropic_tools, + system=system, + ) as stream: + async for text in stream.text_stream: + print(text, end="", flush=True) + response = await stream.get_final_message() + print() + + del messages[-1]["content"][-1]["cache_control"] + + usage = response.usage + print(f"Stop reason: {response.stop_reason}") + print( + f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " + f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " + f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_results: List[ToolResultBlockParam] = [] + for block in response.content: + if block.type != "tool_use": + continue + + print(f"\nTool ({block.name}): {block.input}") + + target_session, original_name = dispatch[block.name] + result = await target_session.call_tool(original_name, block.input) + result_str = result.content[0].text + + result_path = save_to_tmp( + result_str, prefix="tool_result", extension="txt" + ) + print(f"Tool result saved to: {result_path}") + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result_str, + } + ) + + if not tool_results: + break + + messages.append({"role": "user", "content": tool_results}) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + transcript_filename = f"messages_transcript_{timestamp}.json" + transcript_path = f"/tmp/{transcript_filename}" + + with open(transcript_path, "w") as f: + json.dump(messages, f, indent=2, default=str) + + print(f"\nFull transcript saved to: {transcript_path}") + + final_answer = response.content[-1].text + print(f" Final Answer: {final_answer}") + + verify_kwargs: dict = {"final_answer": final_answer} + if verifier_accepts_conversation(task.verifier_func): + # Per Theseus orchestrator's `pass_conversation_to_verifier` contract, + # `conversation` is a "JSON serialized format" string. Verifiers that want + # the list back can `json.loads(conversation)`; verifiers that just want to + # concatenate it into a prompt can do so without `str + list` errors. + verify_kwargs["conversation"] = json.dumps( + to_openai_conversation(system, messages) + ) + print("Verifier accepts `conversation` param; passing it as a JSON string.") + else: + print("Verifier does not accept `conversation` param; skipping.") + + result = await task.verify_detailed_async(env, **verify_kwargs) + print(f"Verifier error:", result.error) + print(f"Verifier stdout:", result.stdout) + print(f"Reward score:", result.result) + + await env.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/fleet_harness.py b/fleet_harness.py index 905e3595..65fab86a 100644 --- a/fleet_harness.py +++ b/fleet_harness.py @@ -341,12 +341,19 @@ async def main(): verify_kwargs: dict = {"final_answer": final_answer} if verifier_accepts_conversation(task.verifier_func): - verify_kwargs["conversation"] = to_openai_conversation(system, messages) - print("Verifier accepts `conversation` param; passing it.") + # Per Theseus orchestrator's `pass_conversation_to_verifier` contract, + # `conversation` is a "JSON serialized format" string. Verifiers that want + # the list back can `json.loads(conversation)`; verifiers that just want to + # concatenate it into a prompt can do so without `str + list` errors. + verify_kwargs["conversation"] = json.dumps( + to_openai_conversation(system, messages) + ) + print("Verifier accepts `conversation` param; passing it as a JSON string.") else: print("Verifier does not accept `conversation` param; skipping.") result = await task.verify_detailed_async(env, **verify_kwargs) + print(f"Verifier error:", result.error) print(f"Verifier stdout:", result.stdout) print(f"Reward score:", result.result) From 1b452d02830a9bb7367ebabf81f6d299de5be62b Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 20:48:48 -0700 Subject: [PATCH 06/17] Update claw_harness.py --- claw_harness.py | 660 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 649 insertions(+), 11 deletions(-) diff --git a/claw_harness.py b/claw_harness.py index d7b998fa..f532f74d 100644 --- a/claw_harness.py +++ b/claw_harness.py @@ -1,11 +1,13 @@ import ast import asyncio import json +import sqlite3 from contextlib import AsyncExitStack -from datetime import datetime -from typing import Any, List, Optional +from datetime import datetime, timedelta +from typing import Any, Awaitable, Callable, Dict, List, Optional import fleet +import httpx from dotenv import load_dotenv from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -24,7 +26,7 @@ client = AsyncAnthropic() -MODEL = "claude-opus-4-7" +MODEL = "claude-opus-4-6" def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: @@ -46,6 +48,322 @@ def convert_tool_format(tool: Tool) -> ToolParam: } +# ─────────────────── OpenClaw skills + memory integration ─────────────────── +# +# Mirrors what `theseus/orchestrator/temporal` does for tasks that ship an +# `INSTANCE_SKILLS_ROOT` and/or `INSTANCE_MEMORY_ROOT` env var. We talk to the +# env's runner FS directly over HTTP (`/fs/list`, `/fs/file/text`) and inject +# skill descriptions + memory contents into the system prompt, plus three +# on-demand tools (`read_skill`, `memory_get`, `memory_search`). + + +# Both prefaces are copied verbatim from theseus's +# `build_skills_system_prompt_section` / `download_memory_bundle_content` +# so the system prompt is byte-for-byte identical to the orchestrator's. +SKILLS_PROMPT_PREFACE = ( + "At its core, a skill is a folder containing a SKILL.md file. This file " + "includes metadata (name and description, at minimum) and instructions " + "that tell you how to perform a specific task. Skills can also bundle " + "scripts, templates, and reference materials.\n" + "\n" + "You have access to the following skills. Use the `read_skill` tool to " + "load the full instructions for a skill before using it." +) + +MEMORY_PROMPT_PREFACE = ( + "- **MEMORY.md** — long-term memory. Durable facts, preferences, and " + "decisions. Loaded at the start of every DM session.\n" + "- **memory/YYYY-MM-DD.md** — daily notes. Running context and " + "observations. Today and yesterday's notes are loaded automatically." +) + + +class RunnerFs: + """Minimal client for the env runner's `/fs/list` and `/fs/file/text` routes. + + Same endpoints `theseus/orchestrator/temporal/runner_fs_tools.py` uses. + `list` normalizes the response so callers always see `{name, is_dir}` dicts. + """ + + # Runner's `FileInfo` returns `file_type`; older fixtures use `type`/`kind`. + _DIR_TYPES = frozenset({"dir", "directory", "folder"}) + + def __init__(self, base_url: str, timeout: float = 30.0): + self.base_url = base_url.rstrip("/") + self._client = httpx.AsyncClient(timeout=timeout) + + async def aclose(self) -> None: + await self._client.aclose() + + async def list(self, path: str) -> List[Dict[str, Any]]: + r = await self._client.get(f"{self.base_url}/fs/list", params={"path": path}) + r.raise_for_status() + data = r.json() + if isinstance(data, list): + raw = data + elif isinstance(data, dict): + raw = next( + ( + data[k] + for k in ("entries", "items", "files", "results", "children") + if isinstance(data.get(k), list) + ), + [], + ) + else: + raw = [] + + out: List[Dict[str, Any]] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + name = ( + entry.get("name") + or entry.get("filename") + or entry.get("basename") + or ( + entry["path"].rsplit("/", 1)[-1] + if isinstance(entry.get("path"), str) + else None + ) + ) + if not name: + continue + t = entry.get("file_type") or entry.get("type") or entry.get("kind") + is_dir = (isinstance(t, str) and t.lower() in self._DIR_TYPES) or ( + entry.get("is_dir") is True + ) + out.append({"name": name, "is_dir": is_dir}) + return out + + async def read_text(self, path: str) -> str: + r = await self._client.post( + f"{self.base_url}/fs/file/text", json={"path": path} + ) + r.raise_for_status() + return r.text + + +def parse_skill_description(md_text: str) -> str: + """Return the first non-blank line that doesn't start with `#` or `---`. + + This matches theseus's naive `_extract_skill_description`: for SKILL.md + files with YAML frontmatter the first matching line is the `name:` field + inside the frontmatter, which is what the orchestrator's prompt shows + (e.g. `- **bash-scripting**: name: bash-scripting`). Skipping the + frontmatter would return the prose description below it instead and + break parity. + """ + for line in md_text.split("\n"): + s = line.strip() + if s and not s.startswith("#") and not s.startswith("---"): + return s + return "" + + +async def load_openclaw_skills( + runner: RunnerFs, skills_root: str +) -> List[Dict[str, str]]: + """One-level walk of skills_root. Each subfolder with a SKILL.md becomes a skill.""" + skills_root = skills_root.rstrip("/") + # Retry: the runner FS often 502s for the first few seconds after MCP + # comes up. 4 attempts at 1.5s spacing covers the warmup window. + last_error: Optional[BaseException] = None + for attempt in range(4): + try: + entries = await runner.list(skills_root) + break + except Exception as e: + last_error = e + if attempt < 3: + await asyncio.sleep(1.5) + else: + print(f" Skills: failed to list {skills_root}: {last_error}") + return [] + + skills: List[Dict[str, str]] = [] + for entry in entries: + # Top-level SKILL.md is intentionally skipped per OpenClaw spec — + # every skill lives in `//SKILL.md`. + if not entry["is_dir"]: + continue + try: + content = await runner.read_text(f"{skills_root}/{entry['name']}/SKILL.md") + except Exception: + continue + if not content or content.lstrip().startswith(" Dict[str, Any]: + """Load MEMORY.md + today/yesterday daily notes + enumerate every .md path.""" + memory_root = memory_root.rstrip("/") + today = yesterday = "" + if current_date_iso: + try: + dt = datetime.fromisoformat(current_date_iso.replace("Z", "+00:00")) + today = dt.strftime("%Y-%m-%d") + yesterday = (dt - timedelta(days=1)).strftime("%Y-%m-%d") + except ValueError: + pass + + async def _try_read(path: str) -> Optional[str]: + # Retry: the runner FS often 502s during the first few seconds after + # MCP becomes ready (theseus sees the same thing). Without retries, + # MEMORY.md / today / yesterday silently come back empty even though + # the file exists, and the model has to discover them via memory_get. + # 4 attempts at 1.5s spacing covers the warmup window. + last_error: Optional[BaseException] = None + for attempt in range(4): + try: + text = await runner.read_text(path) + except Exception as e: + last_error = e + if attempt < 3: + await asyncio.sleep(1.5) + continue + if not text or text.lstrip().startswith(" {type(last_error).__name__}: {last_error}" + ) + return None + + memory_md = await _try_read(f"{memory_root}/MEMORY.md") + today_md = await _try_read(f"{memory_root}/memory/{today}.md") if today else None + yesterday_md = ( + await _try_read(f"{memory_root}/memory/{yesterday}.md") if yesterday else None + ) + + # Enumerate every .md file under memory_root, descending up to 2 levels. + files: Dict[str, str] = {} + + async def _walk(rel_dir: str, depth: int) -> None: + if depth > 2: + return + abs_dir = memory_root if not rel_dir else f"{memory_root}/{rel_dir}" + try: + entries = await runner.list(abs_dir) + except Exception: + return + for entry in entries: + name = entry["name"] + new_rel = name if not rel_dir else f"{rel_dir}/{name}" + if entry["is_dir"]: + await _walk(new_rel, depth + 1) + elif name.lower().endswith(".md"): + files.setdefault(new_rel, "") # contents lazy-loaded on search + + await _walk("", 0) + + return { + "today": today, + "yesterday": yesterday, + "memory_md": memory_md, + "today_md": today_md, + "yesterday_md": yesterday_md, + "files": files, + } + + +def build_openclaw_system_text( + skills: List[Dict[str, str]], memory: Dict[str, Any] +) -> str: + """Render Available Skills + Memory System sections for the system prompt.""" + parts: List[str] = [] + + if skills: + bullets = "\n".join(f"- **{s['name']}**: {s['description']}" for s in skills) + parts.append( + f"\n\n## Available Skills\n\n{SKILLS_PROMPT_PREFACE}\n\n{bullets}\n" + ) + + if memory and ( + memory.get("memory_md") + or memory.get("today_md") + or memory.get("yesterday_md") + or memory.get("files") + ): + # Theseus joins memory sections with `\n\n---\n\n` and does NOT add a + # trailing `\n` to each section — matching that exactly avoids the + # extra blank line at EOF that diff -u flags as the only mismatch. + sections = [f"## Memory System\n\n{MEMORY_PROMPT_PREFACE}"] + if memory.get("memory_md"): + sections.append(f"## Memory\n\n{memory['memory_md']}") + if memory.get("today_md"): + sections.append( + f"## Today's notes ({memory['today']})\n\n{memory['today_md']}" + ) + if memory.get("yesterday_md"): + sections.append( + f"## Yesterday's notes ({memory['yesterday']})\n\n" + f"{memory['yesterday_md']}" + ) + parts.append("\n\n" + "\n\n---\n\n".join(sections)) + + return "".join(parts) + + +def memory_search_fts5(query: str, documents: List[tuple]) -> str: + """SQLite FTS5 BM25 ranked search across memory file contents. + + Mirrors theseus's `fts5_search` + `search_memory_from_fs` exactly: + - Tokens are double-quoted and internal quotes doubled per the FTS5 spec + (otherwise queries with colons / punctuation crash with `fts5: syntax + error near ":"`). + - Returns FULL file content for each ranked hit (top 10), not snippets. + - Result format: `### {filename} (relevance: {score:.2f})\\n{content}` + joined by `\\n\\n---\\n\\n`. Score is `-rank` (FTS5 rank is negative; + closer to 0 = better, so we negate for display). + - Empty query, no docs, or no matches all return the same string — + `"No matching memories found."` — matching theseus. + """ + if not documents: + return "No matching memories found." + safe_query = " ".join( + '"{}"'.format(tok.replace('"', '""')) for tok in query.split() if tok.strip() + ) + if not safe_query: + return "No matching memories found." + + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE VIRTUAL TABLE memory_fts USING fts5(" + ' filename, content, tokenize="unicode61"' + ")" + ) + conn.executemany("INSERT INTO memory_fts VALUES (?, ?)", documents) + rows = conn.execute( + "SELECT filename, content, rank " + "FROM memory_fts WHERE content MATCH ? " + "ORDER BY rank LIMIT ?", + (safe_query, 10), + ).fetchall() + except sqlite3.OperationalError: + return "No matching memories found." + finally: + conn.close() + if not rows: + return "No matching memories found." + sections = [ + f"### {fn} (relevance: {-rank:.2f})\n{content}" for fn, content, rank in rows + ] + return "\n\n---\n\n".join(sections) + + def _block_field(block: Any, key: str) -> Any: if isinstance(block, dict): return block.get(key) @@ -222,10 +540,85 @@ async def main(): print(f"App URL: {env.urls.app[0]}") + # OpenClaw integration: detect via task env_variables and load skills/memory + # from the env's runner FS. No-op for plain tasks. + env_vars = task.env_variables or {} + skills_root = env_vars.get("INSTANCE_SKILLS_ROOT") + memory_root = env_vars.get("INSTANCE_MEMORY_ROOT") + current_date = env_vars.get("CURRENT_DATE") + fs_root = env_vars.get("INSTANCE_FILESYSTEM_ROOT") + print( + f"OpenClaw env vars: {sorted(k for k in env_vars if k.startswith(('INSTANCE_', 'CURRENT_')))}" + ) + + skills: List[Dict[str, str]] = [] + memory_data: Dict[str, Any] = {} + runner_fs: Optional[RunnerFs] = None + runner_api_url: Optional[str] = None + + openclaw_active = bool(skills_root or memory_root or fs_root or current_date) + if openclaw_active: + # Per the openclaw spec, runner_api_url piggy-backs on the first MCP URL: + # strip "/mcp", append "/api/v1/env". Theseus's `_derive_runner_api_url` + # uses the same construction for multi-app envs (per-app prefix is fine — + # the runner proxies through it). + first_mcp_url = endpoints[0][1] + runner_api_url = first_mcp_url[: -len("/mcp")] + "/api/v1/env" + runner_fs = RunnerFs(runner_api_url) + print(f"Runner FS API: {runner_api_url}") + + if skills_root: + print(f"Loading skills from {skills_root}...") + skills = await load_openclaw_skills(runner_fs, skills_root) + print(f" Loaded {len(skills)} skills") + + if memory_root: + print(f"Loading memory from {memory_root}...") + memory_data = await load_openclaw_memory( + runner_fs, memory_root, current_date + ) + print( + f" MEMORY.md={'yes' if memory_data.get('memory_md') else 'no'}, " + f"today({memory_data.get('today') or '-'})=" + f"{'yes' if memory_data.get('today_md') else 'no'}, " + f"yesterday({memory_data.get('yesterday') or '-'})=" + f"{'yes' if memory_data.get('yesterday_md') else 'no'}, " + f"all .md={len(memory_data.get('files', {}))}" + ) + + system_text = ( + "You are a helpful agent. Complete the task. The session ends when you " + "stop calling tools. Avoid unnecessary actions, as side effects may be " + "graded as task failure." + ) + if current_date: + system_text += f"\n\nToday's date is {current_date}." + if fs_root and runner_api_url: + # Mirrors theseus's `get_runner_bash_system_prompt(fs_root)` exactly so + # the agent gets the same input/deliverable contract. + system_text += ( + "\n\nYou have a bash tool (runner_bash__bash) that runs shell " + "commands inside the environment container.\n\n" + f"- **Inputs** for this task live under {fs_root}. If the task " + 'refers to documents, data, or files you were "given," look there ' + f"first (`ls {fs_root}`, `find {fs_root} -type f`).\n" + "- **Deliverables** go under /root/artifacts/. The verifier reads " + "that directory — write final outputs there with `cp`, `tee`, " + 'heredocs, or `>` redirection. Example: `echo "..." > ' + "/root/artifacts/report.md`.\n" + "- Scratch files can go in /tmp.\n" + "- The working directory persists across calls (`cd` carries " + "over); env vars and aliases do not." + ) + system_text += build_openclaw_system_text(skills, memory_data) + + sys_path = save_to_tmp(system_text, prefix="system_prompt", extension="txt") + print(f"System prompt ({len(system_text)} chars) saved to: {sys_path}") + system: List[TextBlockParam] = [ { "type": "text", - "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", + "text": system_text, "cache_control": {"type": "ephemeral"}, } ] @@ -238,8 +631,17 @@ async def main(): async with AsyncExitStack() as stack: anthropic_tools: List[ToolParam] = [] - # namespaced tool name -> (session, original mcp tool name) - dispatch: dict = {} + # namespaced tool name -> async callable taking input dict, returning str + dispatch: Dict[str, Callable[[Dict[str, Any]], Awaitable[str]]] = {} + + def make_mcp_handler( + session: ClientSession, original_name: str + ) -> Callable[[Dict[str, Any]], Awaitable[str]]: + async def handler(input: Dict[str, Any]) -> str: + result = await session.call_tool(original_name, input) + return result.content[0].text + + return handler for app_name, url in endpoints: read_stream, write_stream, _ = await stack.enter_async_context( @@ -260,13 +662,241 @@ async def main(): if prefix: tool_param["name"] = f"{prefix}{mcp_tool.name}" anthropic_tools.append(tool_param) - dispatch[tool_param["name"]] = (session, mcp_tool.name) + dispatch[tool_param["name"]] = make_mcp_handler(session, mcp_tool.name) + + # OpenClaw on-demand tools. + # All descriptions and behavior are ported verbatim from theseus's + # `build_*_tool_definition` + `*_from_fs` helpers in + # `orchestrator/temporal/skill_memory_utils.py` so every tool's + # surface (name, description, parameter copy, return shape, error + # strings) is byte-for-byte identical. + + if skills and runner_fs is not None and skills_root: + skill_choices = [s["name"] for s in skills] + anthropic_tools.append( + { + "name": "read_skill", + "description": ( + "Load the full instructions for a skill. Call this " + "before using a skill to get detailed instructions, " + "code examples, and reference materials." + ), + "input_schema": { + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": ( + "Name of the skill to load. " + f"Available: {', '.join(skill_choices)}" + ), + } + }, + "required": ["skill_name"], + }, + } + ) + + skills_root_clean = skills_root.rstrip("/") + + async def read_skill_handler(input: Dict[str, Any]) -> str: + # Mirrors `read_skill_from_fs`: re-read SKILL.md from the + # runner each call (no caching). + skill_name = input.get("skill_name", "") + abs_path = f"{skills_root_clean}/{skill_name}/SKILL.md" + try: + return await runner_fs.read_text(abs_path) + except Exception as exc: + return f"Error: skill '{skill_name}' not found at {abs_path}: {exc}" + + dispatch["read_skill"] = read_skill_handler + + if memory_data and runner_fs is not None and memory_root: + memory_root_clean = memory_root.rstrip("/") + + anthropic_tools.append( + { + "name": "memory_get", + "description": ( + "Read a specific memory file by name. Use this to " + "access daily notes or other memory files." + ), + "input_schema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": ( + "The memory filename to read (e.g., " + "'MEMORY.md', 'memory/2024-01-15.md')" + ), + } + }, + "required": ["filename"], + }, + } + ) + anthropic_tools.append( + { + "name": "memory_search", + "description": ( + "Search across memory files for relevant information. " + "Returns matching filenames and snippets." + ), + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to find relevant memories", + } + }, + "required": ["query"], + }, + } + ) + + async def memory_get_handler(input: Dict[str, Any]) -> str: + # Mirrors `read_memory_file_from_fs`: no pre-validation, just + # join the path and try to read it. Same error string. + filename = input.get("filename", "") + abs_path = f"{memory_root_clean}/{filename}" + try: + return await runner_fs.read_text(abs_path) + except Exception as exc: + return ( + f"Error: memory file '{filename}' not found at " + f"{abs_path}: {exc}" + ) + + async def memory_search_handler(input: Dict[str, Any]) -> str: + # Mirrors `search_memory_from_fs`: re-walk the memory root, + # re-read every .md file, then run FTS5. Theseus does this + # fresh on every call rather than relying on an indexed cache, + # so we do the same. + query = input.get("query", "") + + filenames: List[str] = [] + + async def _walk(rel_dir: str, depth: int) -> None: + if depth > 2: + return + abs_dir = ( + memory_root_clean + if not rel_dir + else f"{memory_root_clean}/{rel_dir}" + ) + try: + entries = await runner_fs.list(abs_dir) + except Exception: + return + for entry in entries: + name = entry["name"] + new_rel = name if not rel_dir else f"{rel_dir}/{name}" + if entry["is_dir"]: + await _walk(new_rel, depth + 1) + elif name.lower().endswith(".md"): + filenames.append(new_rel) + + await _walk("", 0) + if not filenames: + return "No matching memories found." + + documents: List[tuple] = [] + for fn in filenames: + try: + text = await runner_fs.read_text(f"{memory_root_clean}/{fn}") + except Exception: + continue + documents.append((fn, text)) + + return memory_search_fts5(query, documents) + + dispatch["memory_get"] = memory_get_handler + dispatch["memory_search"] = memory_search_handler + + if fs_root and runner_api_url: + # Mirrors theseus's `runner_bash__bash` tool exactly: POST to + # {runner_api_url}/bash with {command, timeout_ms}. The runner + # persists the working directory across calls but not env vars. + # Theseus raises on missing/empty command and on HTTP errors — + # we let those propagate; the outer dispatch loop turns them + # into a `Tool error (...)` string for the model. + bash_url = f"{runner_api_url.rstrip('/')}/bash" + DEFAULT_TIMEOUT_MS = 120_000 + MAX_TIMEOUT_MS = 600_000 + anthropic_tools.append( + { + "name": "runner_bash__bash", + "description": ( + "Execute a bash command inside the environment " + f"container and return its stdout, stderr, and exit " + f"code. The environment root is {fs_root}; write " + "deliverables to /root/artifacts/. Working directory " + "persists across calls; shell env vars do not." + ), + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute.", + }, + "timeout": { + "type": "integer", + "description": ( + "Max execution time in milliseconds. " + f"Default {DEFAULT_TIMEOUT_MS} (2 min), " + f"max {MAX_TIMEOUT_MS} (10 min)." + ), + }, + }, + "required": ["command"], + }, + } + ) + + async def bash_handler(input: Dict[str, Any]) -> str: + command = (input or {}).get("command") + if not isinstance(command, str) or not command: + raise ValueError("bash tool requires a non-empty `command` string") + timeout_ms = (input or {}).get("timeout", DEFAULT_TIMEOUT_MS) + if not isinstance(timeout_ms, int) or timeout_ms <= 0: + timeout_ms = DEFAULT_TIMEOUT_MS + timeout_ms = min(timeout_ms, MAX_TIMEOUT_MS) + http_timeout = httpx.Timeout( + connect=10.0, + read=(timeout_ms / 1000.0) + 30.0, + write=30.0, + pool=10.0, + ) + async with httpx.AsyncClient(timeout=http_timeout) as c: + r = await c.post( + bash_url, + json={"command": command, "timeout_ms": timeout_ms}, + ) + r.raise_for_status() + body = r.json() + return ( + body + if isinstance(body, str) + else json.dumps(body, ensure_ascii=False) + ) + + dispatch["runner_bash__bash"] = bash_handler if anthropic_tools: anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} + openclaw_tools = [ + n + for n in dispatch + if n in {"read_skill", "memory_get", "memory_search", "runner_bash__bash"} + ] print( - f"Loaded {len(anthropic_tools)} tools across {len(endpoints)} MCP endpoint(s)" + f"Loaded {len(anthropic_tools)} tools " + f"({len(anthropic_tools) - len(openclaw_tools)} from {len(endpoints)} MCP endpoint(s), " + f"{len(openclaw_tools)} OpenClaw: {openclaw_tools or '-'})" ) while True: @@ -307,9 +937,14 @@ async def main(): print(f"\nTool ({block.name}): {block.input}") - target_session, original_name = dispatch[block.name] - result = await target_session.call_tool(original_name, block.input) - result_str = result.content[0].text + handler = dispatch.get(block.name) + if handler is None: + result_str = f"Unknown tool: {block.name}" + else: + try: + result_str = await handler(block.input or {}) + except Exception as e: + result_str = f"Tool error ({type(e).__name__}): {e}" result_path = save_to_tmp( result_str, prefix="tool_result", extension="txt" @@ -359,6 +994,9 @@ async def main(): print(f"Verifier stdout:", result.stdout) print(f"Reward score:", result.result) + if runner_fs is not None: + await runner_fs.aclose() + await env.close() From 819361f6370de2e29f32305f066d4ed795fd1928 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 20:57:08 -0700 Subject: [PATCH 07/17] Update claw_harness.py --- claw_harness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claw_harness.py b/claw_harness.py index f532f74d..f213bf02 100644 --- a/claw_harness.py +++ b/claw_harness.py @@ -510,7 +510,7 @@ async def wait_for_mcp( async def main(): tasks = await fleet.load_tasks_async( - keys=["task_x4zukk7uk6sj_n_1776210959854_ixm2x50h2_bash"] + keys=["task_s2ttghbmpwpy_n_1776197545848_lf64q819a_bash"] ) task = tasks[0] From e527364ed42f9039e0addc884b773ae00c55d16c Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:04:47 -0700 Subject: [PATCH 08/17] Update claw_harness.py --- claw_harness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claw_harness.py b/claw_harness.py index f213bf02..45e64c83 100644 --- a/claw_harness.py +++ b/claw_harness.py @@ -26,7 +26,7 @@ client = AsyncAnthropic() -MODEL = "claude-opus-4-6" +MODEL = "claude-opus-4-7" def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: From bb619b3fcf9fd50ac0a71b88031af62b2e38e861 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:07:03 -0700 Subject: [PATCH 09/17] Create browser_harness.py --- browser_harness.py | 883 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 883 insertions(+) create mode 100644 browser_harness.py diff --git a/browser_harness.py b/browser_harness.py new file mode 100644 index 00000000..0255e34e --- /dev/null +++ b/browser_harness.py @@ -0,0 +1,883 @@ +import ast +import asyncio +import base64 +import json +import sys +from datetime import datetime +from typing import Any, List, Literal, Optional + +import fleet +import playwright.async_api +import pydantic +from anthropic import AsyncAnthropic +from anthropic.types.beta import ( + BetaMessageParam, + BetaTextBlockParam, + BetaToolUnionParam, + BetaToolResultBlockParam, +) +from dotenv import load_dotenv +from playwright.async_api import async_playwright + +load_dotenv() + + +client = AsyncAnthropic() + + +MODEL = "claude-opus-4-7" +DISPLAY_WIDTH = 1366 +DISPLAY_HEIGHT = 768 +COMPUTER_TOOL_TYPE = "computer_20251124" +BETA_HEADER = "computer-use-2025-11-24" +MAX_TURNS = 200 +HEADLESS = True + + +def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filename = f"{prefix}_{timestamp}.{extension}" + filepath = f"/tmp/{filename}" + + with open(filepath, "w") as f: + f.write(content) + + return filepath + + +def save_screenshot(screenshot_bytes: bytes, prefix: str = "screenshot") -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + filepath = f"/tmp/{prefix}_{timestamp}.png" + with open(filepath, "wb") as f: + f.write(screenshot_bytes) + return filepath + + +def _block_field(block: Any, key: str) -> Any: + if isinstance(block, dict): + return block.get(key) + return getattr(block, key, None) + + +def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: + """Return True if the verifier's top-level function declares a `conversation` param.""" + if not verifier_func: + return False + try: + tree = ast.parse(verifier_func) + except SyntaxError: + return False + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + args = node.args + param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} + return "conversation" in param_names + return False + + +def to_openai_conversation( + system: List[BetaTextBlockParam], + messages: List[BetaMessageParam], +) -> List[dict]: + """Convert Anthropic-format system+messages into OpenAI chat schema. + + Same shape as fleet_harness.py's helper. Computer-use tool_use blocks + serialize their action dict into the `function.arguments` JSON string; + image-bearing tool_result blocks collapse to a `[screenshot]` placeholder + so the conversation stays text-only for verifiers. + """ + out: List[dict] = [] + + system_text = "\n\n".join( + b["text"] for b in system if b.get("type") == "text" and b.get("text") + ) + if system_text: + out.append({"role": "system", "content": system_text}) + + for msg in messages: + role = msg["role"] + content = msg["content"] + blocks = [content] if isinstance(content, str) else list(content) + + if role == "user": + text_parts: List[str] = [] + tool_msgs: List[dict] = [] + for block in blocks: + if isinstance(block, str): + text_parts.append(block) + continue + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_result": + tool_content = _block_field(block, "content") + if isinstance(tool_content, list): + rendered: List[str] = [] + for sub in tool_content: + sub_type = _block_field(sub, "type") + if sub_type == "text": + rendered.append(_block_field(sub, "text") or "") + elif sub_type == "image": + rendered.append("[screenshot]") + tool_content = "\n".join(rendered) if rendered else "" + elif not isinstance(tool_content, str): + tool_content = json.dumps(tool_content, default=str) + tool_msgs.append( + { + "role": "tool", + "tool_call_id": _block_field(block, "tool_use_id"), + "content": tool_content, + } + ) + + if text_parts: + out.append({"role": "user", "content": "\n".join(text_parts)}) + out.extend(tool_msgs) + continue + + if role == "assistant": + text_parts = [] + tool_calls: List[dict] = [] + for block in blocks: + btype = _block_field(block, "type") + if btype == "text": + text_parts.append(_block_field(block, "text") or "") + elif btype == "tool_use": + tool_calls.append( + { + "id": _block_field(block, "id"), + "type": "function", + "function": { + "name": _block_field(block, "name"), + "arguments": json.dumps( + _block_field(block, "input") or {} + ), + }, + } + ) + + entry: dict = { + "role": "assistant", + "content": "\n".join(text_parts) if text_parts else None, + } + if tool_calls: + entry["tool_calls"] = tool_calls + out.append(entry) + + return out + + +# --------------------------------------------------------------------------- +# Playwright browser +# --------------------------------------------------------------------------- + +# Map Anthropic / xdotool key names to Playwright key names. +# Anthropic's `key` action uses xdotool syntax: e.g. "Return", "ctrl+s", +# "Page_Down", "shift+Tab". We split on '+', map each token, and feed it back +# to Playwright. Unknown tokens (single chars like 'a') pass through. +PLAYWRIGHT_KEY_MAP = { + "return": "Enter", + "enter": "Enter", + "kp_enter": "Enter", + "tab": "Tab", + "backspace": "Backspace", + "delete": "Delete", + "escape": "Escape", + "esc": "Escape", + "space": " ", + "shift": "Shift", + "shift_l": "Shift", + "shift_r": "Shift", + "ctrl": "Control", + "control": "Control", + "control_l": "Control", + "control_r": "Control", + "alt": "Alt", + "alt_l": "Alt", + "alt_r": "Alt", + "meta": "Meta", + "super": "Meta", + "super_l": "Meta", + "super_r": "Meta", + "cmd": "Meta", + "command": "Meta", + "win": "Meta", + "left": "ArrowLeft", + "right": "ArrowRight", + "up": "ArrowUp", + "down": "ArrowDown", + "page_up": "PageUp", + "pageup": "PageUp", + "page_down": "PageDown", + "pagedown": "PageDown", + "home": "Home", + "end": "End", + "insert": "Insert", + "caps_lock": "CapsLock", + "f1": "F1", + "f2": "F2", + "f3": "F3", + "f4": "F4", + "f5": "F5", + "f6": "F6", + "f7": "F7", + "f8": "F8", + "f9": "F9", + "f10": "F10", + "f11": "F11", + "f12": "F12", + "minus": "-", + "plus": "+", + "equal": "=", + "underscore": "_", + "slash": "/", + "backslash": "\\", + "semicolon": ";", + "apostrophe": "'", + "grave": "`", + "comma": ",", + "period": ".", + "bracketleft": "[", + "bracketright": "]", +} + + +def normalize_key(token: str) -> str: + return PLAYWRIGHT_KEY_MAP.get(token.lower(), token) + + +def parse_key_combo(text: str) -> List[str]: + """Parse an xdotool-style key string ('ctrl+shift+t') into Playwright keys.""" + return [normalize_key(t) for t in text.split("+") if t] + + +class EnvState(pydantic.BaseModel): + screenshot: bytes + url: str + + +class PlaywrightComputer: + """Local Playwright browser exposed through Claude computer-use semantics. + + Coordinates here are *absolute pixels* (no 0-1000 normalization), matching + Claude Opus 4.7's 1:1 pixel-to-coordinate behavior. + """ + + def __init__( + self, + screen_size: tuple[int, int], + initial_url: str, + headless: bool = True, + highlight_mouse: bool = False, + ): + self._initial_url = initial_url + self._screen_size = screen_size + self._headless = headless + self._highlight_mouse = highlight_mouse + + async def _handle_new_page(self, new_page: playwright.async_api.Page): + """Computer use is single-tab; redirect new tabs into the main page.""" + new_url = new_page.url + await new_page.close() + if new_url and new_url != "about:blank": + await self._page.goto(new_url) + + async def __aenter__(self): + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.launch( + args=[ + "--disable-extensions", + "--disable-file-system", + "--disable-plugins", + "--disable-dev-shm-usage", + "--disable-background-networking", + "--disable-default-apps", + "--disable-sync", + ], + headless=self._headless, + ) + self._context = await self._browser.new_context( + viewport={ + "width": self._screen_size[0], + "height": self._screen_size[1], + } + ) + self._page = await self._context.new_page() + await self._page.goto(self._initial_url) + self._context.on("page", self._handle_new_page) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._context: + await self._context.close() + try: + await self._browser.close() + except Exception as e: + if "Browser.close: Connection closed while reading from the driver" in str( + e + ): + pass + else: + raise + await self._playwright.stop() + + async def _safe_wait_for_load(self, timeout_ms: int = 5000): + try: + await self._page.wait_for_load_state(timeout=timeout_ms) + except playwright.async_api.TimeoutError: + pass + + async def screenshot(self) -> EnvState: + await self._safe_wait_for_load() + await asyncio.sleep(0.4) + png = await self._page.screenshot(type="png", full_page=False) + return EnvState(screenshot=png, url=self._page.url) + + async def highlight_mouse(self, x: int, y: int): + if not self._highlight_mouse: + return + await self._page.evaluate( + """([x, y]) => { + const div = document.createElement('div'); + div.style.pointerEvents = 'none'; + div.style.border = '4px solid red'; + div.style.borderRadius = '50%'; + div.style.width = '20px'; + div.style.height = '20px'; + div.style.position = 'fixed'; + div.style.zIndex = '999999'; + div.style.left = (x - 10) + 'px'; + div.style.top = (y - 10) + 'px'; + document.body.appendChild(div); + setTimeout(() => div.remove(), 1500); + }""", + [x, y], + ) + + async def _click( + self, + x: int, + y: int, + button: Literal["left", "right", "middle"] = "left", + click_count: int = 1, + modifiers: Optional[List[str]] = None, + ) -> EnvState: + await self.highlight_mouse(x, y) + await self._page.mouse.click( + x, y, button=button, click_count=click_count, modifiers=modifiers or [] + ) + await self._safe_wait_for_load() + return await self.screenshot() + + async def left_click( + self, x: int, y: int, modifiers: Optional[List[str]] = None + ) -> EnvState: + return await self._click(x, y, "left", modifiers=modifiers) + + async def right_click( + self, x: int, y: int, modifiers: Optional[List[str]] = None + ) -> EnvState: + return await self._click(x, y, "right", modifiers=modifiers) + + async def middle_click( + self, x: int, y: int, modifiers: Optional[List[str]] = None + ) -> EnvState: + return await self._click(x, y, "middle", modifiers=modifiers) + + async def double_click( + self, x: int, y: int, modifiers: Optional[List[str]] = None + ) -> EnvState: + return await self._click(x, y, "left", click_count=2, modifiers=modifiers) + + async def triple_click( + self, x: int, y: int, modifiers: Optional[List[str]] = None + ) -> EnvState: + return await self._click(x, y, "left", click_count=3, modifiers=modifiers) + + async def mouse_move(self, x: int, y: int) -> EnvState: + await self.highlight_mouse(x, y) + await self._page.mouse.move(x, y) + return await self.screenshot() + + async def left_mouse_down(self, x: Optional[int], y: Optional[int]) -> EnvState: + if x is not None and y is not None: + await self._page.mouse.move(x, y) + await self._page.mouse.down(button="left") + return await self.screenshot() + + async def left_mouse_up(self, x: Optional[int], y: Optional[int]) -> EnvState: + if x is not None and y is not None: + await self._page.mouse.move(x, y) + await self._page.mouse.up(button="left") + return await self.screenshot() + + async def left_click_drag( + self, start: tuple[int, int], end: tuple[int, int] + ) -> EnvState: + await self.highlight_mouse(*start) + await self._page.mouse.move(*start) + await self._page.mouse.down() + await self.highlight_mouse(*end) + await self._page.mouse.move(*end, steps=20) + await self._page.mouse.up() + await self._safe_wait_for_load() + return await self.screenshot() + + async def type_text(self, text: str) -> EnvState: + await self._page.keyboard.type(text) + await self._safe_wait_for_load() + return await self.screenshot() + + async def key(self, text: str) -> EnvState: + keys = parse_key_combo(text) + if not keys: + return await self.screenshot() + for k in keys[:-1]: + await self._page.keyboard.down(k) + await self._page.keyboard.press(keys[-1]) + for k in reversed(keys[:-1]): + await self._page.keyboard.up(k) + await self._safe_wait_for_load() + return await self.screenshot() + + async def hold_key(self, text: str, duration: float) -> EnvState: + keys = parse_key_combo(text) + for k in keys: + await self._page.keyboard.down(k) + await asyncio.sleep(min(duration, 10.0)) + for k in reversed(keys): + await self._page.keyboard.up(k) + return await self.screenshot() + + async def scroll( + self, + x: int, + y: int, + direction: Literal["up", "down", "left", "right"], + amount: int, + modifiers: Optional[List[str]] = None, + ) -> EnvState: + await self._page.mouse.move(x, y) + # `scroll_amount` is in "clicks"; treat each as ~100px. + step = 100 * max(int(amount), 1) + dx, dy = 0, 0 + if direction == "up": + dy = -step + elif direction == "down": + dy = step + elif direction == "left": + dx = -step + elif direction == "right": + dx = step + modifiers = modifiers or [] + for k in modifiers: + await self._page.keyboard.down(k) + await self._page.mouse.wheel(dx, dy) + for k in reversed(modifiers): + await self._page.keyboard.up(k) + await self._safe_wait_for_load() + return await self.screenshot() + + async def wait(self, duration: float) -> EnvState: + await asyncio.sleep(min(duration, 30.0)) + return await self.screenshot() + + async def cursor_position(self) -> EnvState: + # Playwright doesn't expose mouse position; just return a screenshot. + return await self.screenshot() + + +# --------------------------------------------------------------------------- +# Action dispatch +# --------------------------------------------------------------------------- + + +def _coord(value: Any) -> tuple[int, int]: + if value is None: + return 0, 0 + return int(value[0]), int(value[1]) + + +def _modifiers_from_text(text: Optional[str]) -> List[str]: + if not text: + return [] + return [normalize_key(t) for t in text.split("+") if t] + + +async def execute_computer_action( + computer: PlaywrightComputer, tool_input: dict +) -> tuple[EnvState | None, str | None, bool]: + """Run a computer-use tool call. + + Returns `(state, error_text, is_error)`. Exactly one of `state`/`error_text` + is set. Caller turns `state` into an image tool_result block; `error_text` + into a text tool_result with `is_error=True`. + """ + action = tool_input.get("action") + try: + if action == "screenshot": + return await computer.screenshot(), None, False + + if action == "left_click": + x, y = _coord(tool_input.get("coordinate")) + return ( + await computer.left_click( + x, y, _modifiers_from_text(tool_input.get("text")) + ), + None, + False, + ) + + if action == "right_click": + x, y = _coord(tool_input.get("coordinate")) + return ( + await computer.right_click( + x, y, _modifiers_from_text(tool_input.get("text")) + ), + None, + False, + ) + + if action == "middle_click": + x, y = _coord(tool_input.get("coordinate")) + return ( + await computer.middle_click( + x, y, _modifiers_from_text(tool_input.get("text")) + ), + None, + False, + ) + + if action == "double_click": + x, y = _coord(tool_input.get("coordinate")) + return ( + await computer.double_click( + x, y, _modifiers_from_text(tool_input.get("text")) + ), + None, + False, + ) + + if action == "triple_click": + x, y = _coord(tool_input.get("coordinate")) + return ( + await computer.triple_click( + x, y, _modifiers_from_text(tool_input.get("text")) + ), + None, + False, + ) + + if action == "mouse_move": + x, y = _coord(tool_input.get("coordinate")) + return await computer.mouse_move(x, y), None, False + + if action == "left_mouse_down": + coord = tool_input.get("coordinate") + x, y = _coord(coord) if coord else (None, None) + return await computer.left_mouse_down(x, y), None, False + + if action == "left_mouse_up": + coord = tool_input.get("coordinate") + x, y = _coord(coord) if coord else (None, None) + return await computer.left_mouse_up(x, y), None, False + + if action == "left_click_drag": + start = _coord(tool_input.get("start_coordinate")) + end = _coord(tool_input.get("coordinate")) + return await computer.left_click_drag(start, end), None, False + + if action == "type": + text = tool_input.get("text") or "" + return await computer.type_text(text), None, False + + if action == "key": + text = tool_input.get("text") or "" + return await computer.key(text), None, False + + if action == "hold_key": + text = tool_input.get("text") or "" + duration = float(tool_input.get("duration", 1)) + return await computer.hold_key(text, duration), None, False + + if action == "scroll": + x, y = _coord(tool_input.get("coordinate")) + direction = tool_input.get("scroll_direction", "down") + amount = int(tool_input.get("scroll_amount", 3)) + return ( + await computer.scroll( + x, + y, + direction, + amount, + _modifiers_from_text(tool_input.get("text")), + ), + None, + False, + ) + + if action == "wait": + duration = float(tool_input.get("duration", 1)) + return await computer.wait(duration), None, False + + if action == "cursor_position": + return await computer.cursor_position(), None, False + + return None, f"Unsupported action: {action!r}", True + + except Exception as e: + return None, f"Action {action!r} failed: {type(e).__name__}: {e}", True + + +def screenshot_to_block(state: EnvState) -> dict: + return { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": base64.b64encode(state.screenshot).decode("utf-8"), + }, + } + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + + +async def main(): + tasks = await fleet.load_tasks_async( + keys=["task_intlycg3my7r_n_1769564666070_elnq81y45"] + ) + task = tasks[0] + + print("Task Key:", task.key) + print("Task Prompt:", task.prompt) + + env = await fleet.env.make_async( + env_key=task.env_key, + data_key=task.data_key, + env_variables=task.env_variables, + ttl_seconds=3600, + ) + print("Instance URL:", env.urls.root) + + app_url = env.urls.app[0] if env.urls.app else env.urls.root + print(f"App URL: {app_url}") + + computer_tool: BetaToolUnionParam = { + "type": COMPUTER_TOOL_TYPE, + "name": "computer", + "display_width_px": DISPLAY_WIDTH, + "display_height_px": DISPLAY_HEIGHT, + "display_number": 1, + } + tools: List[BetaToolUnionParam] = [computer_tool] + + system: List[BetaTextBlockParam] = [ + { + "type": "text", + "text": ( + f"You control a Chromium browser via the `computer` tool. " + f"The viewport is {DISPLAY_WIDTH}x{DISPLAY_HEIGHT} pixels and " + f"coordinates are absolute pixels (no normalization). " + f"The browser is already pointed at the task app. Complete the task. " + f"Take a screenshot whenever you need to see the current state. " + f"The session ends when you stop calling tools and emit a final text " + f"answer. Avoid unnecessary actions, as side effects may be graded as " + f"task failure." + ), + "cache_control": {"type": "ephemeral"}, + } + ] + messages: List[BetaMessageParam] = [ + { + "role": "user", + "content": [{"type": "text", "text": task.prompt}], + } + ] + + response = None + final_answer: str = "" + + async with PlaywrightComputer( + screen_size=(DISPLAY_WIDTH, DISPLAY_HEIGHT), + initial_url=app_url, + headless=HEADLESS, + highlight_mouse=not HEADLESS, + ) as computer: + initial = await computer.screenshot() + save_screenshot(initial.screenshot, "initial") + # Seed the model with an initial screenshot so it doesn't waste a turn + # asking for one. + messages.append( + { + "role": "user", + "content": [ + {"type": "text", "text": "Initial browser screenshot:"}, + screenshot_to_block(initial), + ], + } + ) + + for turn in range(1, MAX_TURNS + 1): + print(f"\n{'=' * 50}") + print(f"Turn {turn} - sending {len(messages)} messages") + + messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} + + print("\nAssistant: ", end="", flush=True) + async with client.beta.messages.stream( + model=MODEL, + max_tokens=8192, + messages=messages, + tools=tools, + system=system, + betas=[BETA_HEADER], + ) as stream: + async for text in stream.text_stream: + print(text, end="", flush=True) + response = await stream.get_final_message() + print() + + del messages[-1]["content"][-1]["cache_control"] + + usage = response.usage + print(f"Stop reason: {response.stop_reason}") + print( + f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " + f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " + f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_results: List[BetaToolResultBlockParam] = [] + for block in response.content: + if block.type != "tool_use": + continue + + action = ( + block.input.get("action") if isinstance(block.input, dict) else None + ) + print(f"\nTool ({block.name}): action={action} input={block.input}") + + state, error_text, is_error = await execute_computer_action( + computer, dict(block.input) if isinstance(block.input, dict) else {} + ) + + if is_error or state is None: + print(f" -> error: {error_text}") + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": error_text or "Unknown error", + "is_error": True, + } + ) + continue + + screenshot_path = save_screenshot( + state.screenshot, f"turn{turn}_{action}" + ) + print(f" -> {state.url} | screenshot: {screenshot_path}") + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": [screenshot_to_block(state)], + } + ) + + if not tool_results: + break + + messages.append({"role": "user", "content": tool_results}) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + transcript_filename = f"messages_cu_transcript_{timestamp}.json" + transcript_path = f"/tmp/{transcript_filename}" + + # Strip image data from the transcript so the JSON stays readable. + sanitized: List[dict] = [] + for msg in messages: + content = msg["content"] + if isinstance(content, str): + sanitized.append({"role": msg["role"], "content": content}) + continue + new_blocks = [] + for block in content: + btype = _block_field(block, "type") + if btype == "image": + new_blocks.append({"type": "image", "source": "[omitted]"}) + elif btype == "tool_result": + inner = _block_field(block, "content") + if isinstance(inner, list): + rendered = [] + for sub in inner: + sub_type = _block_field(sub, "type") + if sub_type == "image": + rendered.append( + {"type": "image", "source": "[omitted]"} + ) + else: + rendered.append( + sub if isinstance(sub, dict) else dict(sub.__dict__) + ) + new_blocks.append( + { + "type": "tool_result", + "tool_use_id": _block_field(block, "tool_use_id"), + "content": rendered, + "is_error": _block_field(block, "is_error") or False, + } + ) + else: + new_blocks.append( + { + "type": "tool_result", + "tool_use_id": _block_field(block, "tool_use_id"), + "content": inner, + "is_error": _block_field(block, "is_error") or False, + } + ) + else: + new_blocks.append( + block if isinstance(block, dict) else dict(block.__dict__) + ) + sanitized.append({"role": msg["role"], "content": new_blocks}) + + with open(transcript_path, "w") as f: + json.dump(sanitized, f, indent=2, default=str) + print(f"\nFull transcript saved to: {transcript_path}") + + if response is not None: + for block in reversed(response.content): + if block.type == "text" and block.text: + final_answer = block.text + break + + print(f"\nFinal Answer: {final_answer}") + + verify_kwargs: dict = {"final_answer": final_answer} + if verifier_accepts_conversation(task.verifier_func): + verify_kwargs["conversation"] = json.dumps( + to_openai_conversation(system, messages) + ) + print("Verifier accepts `conversation` param; passing it as a JSON string.") + else: + print("Verifier does not accept `conversation` param; skipping.") + + result = await task.verify_detailed_async(env, **verify_kwargs) + print("Verifier error:", result.error) + print("Verifier stdout:", result.stdout) + print("Reward score:", result.result) + + await env.close() + + +if __name__ == "__main__": + asyncio.run(main()) From 7715d7bd9360bf502bc4927e554bf873a7b0a057 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:27:50 -0700 Subject: [PATCH 10/17] Update browser_harness.py --- browser_harness.py | 136 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 25 deletions(-) diff --git a/browser_harness.py b/browser_harness.py index 0255e34e..d68a17e0 100644 --- a/browser_harness.py +++ b/browser_harness.py @@ -10,11 +10,11 @@ import playwright.async_api import pydantic from anthropic import AsyncAnthropic -from anthropic.types.beta import ( - BetaMessageParam, - BetaTextBlockParam, - BetaToolUnionParam, - BetaToolResultBlockParam, +from anthropic.types import ( + MessageParam, + TextBlockParam, + ToolParam, + ToolResultBlockParam, ) from dotenv import load_dotenv from playwright.async_api import async_playwright @@ -28,10 +28,30 @@ MODEL = "claude-opus-4-7" DISPLAY_WIDTH = 1366 DISPLAY_HEIGHT = 768 -COMPUTER_TOOL_TYPE = "computer_20251124" -BETA_HEADER = "computer-use-2025-11-24" -MAX_TURNS = 200 -HEADLESS = True + +# Action set Claude can pass via the `computer` function tool. Mirrors the +# action vocabulary that theseus's browser-lease fallback exposes (see +# orchestrator/temporal/activities.py::BROWSER_LEASE_COMPUTER_ACTIONS) so the +# behaviour matches what Claude has been prompted on in production. +SUPPORTED_ACTIONS = ( + "navigate", + "screenshot", + "left_click", + "right_click", + "double_click", + "triple_click", + "middle_click", + "mouse_move", + "left_click_drag", + "type", + "key", + "scroll", + "wait", + "cursor_position", + "left_mouse_down", + "left_mouse_up", + "hold_key", +) def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: @@ -77,8 +97,8 @@ def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: def to_openai_conversation( - system: List[BetaTextBlockParam], - messages: List[BetaMessageParam], + system: List[TextBlockParam], + messages: List[MessageParam], ) -> List[dict]: """Convert Anthropic-format system+messages into OpenAI chat schema. @@ -487,6 +507,13 @@ async def cursor_position(self) -> EnvState: # Playwright doesn't expose mouse position; just return a screenshot. return await self.screenshot() + async def navigate(self, url: str) -> EnvState: + if not url.startswith(("http://", "https://", "about:", "data:")): + url = "https://" + url + await self._page.goto(url) + await self._safe_wait_for_load() + return await self.screenshot() + # --------------------------------------------------------------------------- # Action dispatch @@ -519,6 +546,12 @@ async def execute_computer_action( if action == "screenshot": return await computer.screenshot(), None, False + if action == "navigate": + url = tool_input.get("url") or "" + if not url: + return None, "navigate requires a non-empty `url`", True + return await computer.navigate(url), None, False + if action == "left_click": x, y = _coord(tool_input.get("coordinate")) return ( @@ -666,16 +699,69 @@ async def main(): app_url = env.urls.app[0] if env.urls.app else env.urls.root print(f"App URL: {app_url}") - computer_tool: BetaToolUnionParam = { - "type": COMPUTER_TOOL_TYPE, + computer_tool: ToolParam = { "name": "computer", - "display_width_px": DISPLAY_WIDTH, - "display_height_px": DISPLAY_HEIGHT, - "display_number": 1, + "description": ( + f"Control a Chromium browser ({DISPLAY_WIDTH}x{DISPLAY_HEIGHT} viewport, " + "coordinates are absolute pixels from the top-left). Call with `action` " + "and the action-specific parameters. After every action you receive a " + "fresh screenshot back. Use `screenshot` whenever you need to re-observe " + "the page without otherwise changing state." + ), + "input_schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": list(SUPPORTED_ACTIONS), + "description": "The browser action to perform.", + }, + "coordinate": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + "description": "[x, y] in absolute viewport pixels.", + }, + "start_coordinate": { + "type": "array", + "items": {"type": "integer"}, + "minItems": 2, + "maxItems": 2, + "description": "Drag start [x, y]; pair with `coordinate` as the end.", + }, + "text": { + "type": "string", + "description": ( + "For `type`: text to type. " + "For `key`/`hold_key`: xdotool key combo (e.g. 'Return', 'ctrl+a'). " + "For click/scroll: optional modifier (e.g. 'shift', 'ctrl')." + ), + }, + "url": { + "type": "string", + "description": "URL for the `navigate` action.", + }, + "scroll_direction": { + "type": "string", + "enum": ["up", "down", "left", "right"], + }, + "scroll_amount": { + "type": "integer", + "description": "Scroll wheel clicks (~100px each).", + }, + "duration": { + "type": "number", + "description": "Seconds for `wait`/`hold_key` (clamped to 30).", + }, + }, + "required": ["action"], + }, + "cache_control": {"type": "ephemeral"}, } - tools: List[BetaToolUnionParam] = [computer_tool] + tools: List[ToolParam] = [computer_tool] - system: List[BetaTextBlockParam] = [ + system: List[TextBlockParam] = [ { "type": "text", "text": ( @@ -691,7 +777,7 @@ async def main(): "cache_control": {"type": "ephemeral"}, } ] - messages: List[BetaMessageParam] = [ + messages: List[MessageParam] = [ { "role": "user", "content": [{"type": "text", "text": task.prompt}], @@ -704,8 +790,7 @@ async def main(): async with PlaywrightComputer( screen_size=(DISPLAY_WIDTH, DISPLAY_HEIGHT), initial_url=app_url, - headless=HEADLESS, - highlight_mouse=not HEADLESS, + headless=True, ) as computer: initial = await computer.screenshot() save_screenshot(initial.screenshot, "initial") @@ -721,20 +806,21 @@ async def main(): } ) - for turn in range(1, MAX_TURNS + 1): + turn = 0 + while True: + turn += 1 print(f"\n{'=' * 50}") print(f"Turn {turn} - sending {len(messages)} messages") messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} print("\nAssistant: ", end="", flush=True) - async with client.beta.messages.stream( + async with client.messages.stream( model=MODEL, max_tokens=8192, messages=messages, tools=tools, system=system, - betas=[BETA_HEADER], ) as stream: async for text in stream.text_stream: print(text, end="", flush=True) @@ -753,7 +839,7 @@ async def main(): messages.append({"role": "assistant", "content": response.content}) - tool_results: List[BetaToolResultBlockParam] = [] + tool_results: List[ToolResultBlockParam] = [] for block in response.content: if block.type != "tool_use": continue From b16254c75a33d978755ef7a543f56ae01ae3494e Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:38:03 -0700 Subject: [PATCH 11/17] Update browser_harness.py --- browser_harness.py | 92 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/browser_harness.py b/browser_harness.py index d68a17e0..f4a088f0 100644 --- a/browser_harness.py +++ b/browser_harness.py @@ -2,7 +2,6 @@ import asyncio import base64 import json -import sys from datetime import datetime from typing import Any, List, Literal, Optional @@ -205,14 +204,17 @@ def to_openai_conversation( "delete": "Delete", "escape": "Escape", "esc": "Escape", - "space": " ", + "space": "Space", "shift": "Shift", "shift_l": "Shift", "shift_r": "Shift", - "ctrl": "Control", - "control": "Control", - "control_l": "Control", - "control_r": "Control", + # `ControlOrMeta` is Playwright's portability shim: Cmd on macOS, Control + # elsewhere. Matches what gemini_harness uses and what most web apps treat + # as the same shortcut. Works in `keyboard.down/up/press` too. + "ctrl": "ControlOrMeta", + "control": "ControlOrMeta", + "control_l": "ControlOrMeta", + "control_r": "ControlOrMeta", "alt": "Alt", "alt_l": "Alt", "alt_r": "Alt", @@ -290,11 +292,18 @@ def __init__( initial_url: str, headless: bool = True, highlight_mouse: bool = False, + allowed_hosts: Optional[List[str]] = None, ): self._initial_url = initial_url self._screen_size = screen_size self._headless = headless self._highlight_mouse = highlight_mouse + # Lower-cased hostnames the model is allowed to navigate to. Anything + # else gets rejected so a hallucinated `localhost:3000` doesn't fly the + # browser into chrome-error://. + self._allowed_hosts = ( + {h.lower() for h in allowed_hosts} if allowed_hosts else None + ) async def _handle_new_page(self, new_page: playwright.async_api.Page): """Computer use is single-tab; redirect new tabs into the main page.""" @@ -384,9 +393,17 @@ async def _click( modifiers: Optional[List[str]] = None, ) -> EnvState: await self.highlight_mouse(x, y) - await self._page.mouse.click( - x, y, button=button, click_count=click_count, modifiers=modifiers or [] - ) + # Playwright's raw `Mouse.click()` doesn't accept a `modifiers` kwarg + # (only the high-level Page.click does); hold them down manually around + # the click instead, mirroring how `scroll` does it. + held = list(modifiers or []) + for k in held: + await self._page.keyboard.down(k) + try: + await self._page.mouse.click(x, y, button=button, click_count=click_count) + finally: + for k in reversed(held): + await self._page.keyboard.up(k) await self._safe_wait_for_load() return await self.screenshot() @@ -510,6 +527,16 @@ async def cursor_position(self) -> EnvState: async def navigate(self, url: str) -> EnvState: if not url.startswith(("http://", "https://", "about:", "data:")): url = "https://" + url + if self._allowed_hosts is not None: + from urllib.parse import urlparse + + host = (urlparse(url).hostname or "").lower() + if host not in self._allowed_hosts: + raise RuntimeError( + f"navigate refused: host {host!r} is not in the env's " + f"allow-list {sorted(self._allowed_hosts)}. Stay on the app " + f"URLs you were given." + ) await self._page.goto(url) await self._safe_wait_for_load() return await self.screenshot() @@ -696,8 +723,12 @@ async def main(): ) print("Instance URL:", env.urls.root) - app_url = env.urls.app[0] if env.urls.app else env.urls.root - print(f"App URL: {app_url}") + app_url = env.urls.root + app_urls: List[str] = list(env.urls.app or []) + + print(f"App URLs ({len(app_urls)}):") + for url in app_urls: + print(f" {url}") computer_tool: ToolParam = { "name": "computer", @@ -764,16 +795,7 @@ async def main(): system: List[TextBlockParam] = [ { "type": "text", - "text": ( - f"You control a Chromium browser via the `computer` tool. " - f"The viewport is {DISPLAY_WIDTH}x{DISPLAY_HEIGHT} pixels and " - f"coordinates are absolute pixels (no normalization). " - f"The browser is already pointed at the task app. Complete the task. " - f"Take a screenshot whenever you need to see the current state. " - f"The session ends when you stop calling tools and emit a final text " - f"answer. Avoid unnecessary actions, as side effects may be graded as " - f"task failure." - ), + "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", "cache_control": {"type": "ephemeral"}, } ] @@ -787,20 +809,42 @@ async def main(): response = None final_answer: str = "" + from urllib.parse import urlparse + + allowed_hosts = sorted( + { + urlparse(u).hostname.lower() + for u in [env.urls.root, *app_urls] + if urlparse(u).hostname + } + ) + async with PlaywrightComputer( screen_size=(DISPLAY_WIDTH, DISPLAY_HEIGHT), initial_url=app_url, headless=True, + allowed_hosts=allowed_hosts, ) as computer: initial = await computer.screenshot() save_screenshot(initial.screenshot, "initial") - # Seed the model with an initial screenshot so it doesn't waste a turn - # asking for one. + apps_block = "\n".join(f" - {u}" for u in app_urls) or " (none)" + # Seed the model with an initial screenshot + the env URLs so it knows + # exactly where it can navigate (no guessing localhost or external sites). + seed_text = ( + f"You are driving a Chromium browser ({DISPLAY_WIDTH}x{DISPLAY_HEIGHT}, " + f"absolute pixel coordinates) via the `computer` tool. The browser is " + f"currently at the env root: {app_url}\n" + f"\n" + f"Available app URLs (and the only URLs you should navigate to):\n" + f"{apps_block}\n" + f"\n" + f"Initial screenshot:" + ) messages.append( { "role": "user", "content": [ - {"type": "text", "text": "Initial browser screenshot:"}, + {"type": "text", "text": seed_text}, screenshot_to_block(initial), ], } From e871a3bc238aba70a965d648ac45fb4dc244b8f1 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:41:25 -0700 Subject: [PATCH 12/17] Update browser_harness.py --- browser_harness.py | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/browser_harness.py b/browser_harness.py index f4a088f0..3e36e153 100644 --- a/browser_harness.py +++ b/browser_harness.py @@ -723,13 +723,6 @@ async def main(): ) print("Instance URL:", env.urls.root) - app_url = env.urls.root - app_urls: List[str] = list(env.urls.app or []) - - print(f"App URLs ({len(app_urls)}):") - for url in app_urls: - print(f" {url}") - computer_tool: ToolParam = { "name": "computer", "description": ( @@ -811,42 +804,21 @@ async def main(): from urllib.parse import urlparse - allowed_hosts = sorted( - { - urlparse(u).hostname.lower() - for u in [env.urls.root, *app_urls] - if urlparse(u).hostname - } - ) + root_host = (urlparse(env.urls.root).hostname or "").lower() + allowed_hosts = [root_host] if root_host else None async with PlaywrightComputer( screen_size=(DISPLAY_WIDTH, DISPLAY_HEIGHT), - initial_url=app_url, + initial_url=env.urls.root, headless=True, allowed_hosts=allowed_hosts, ) as computer: initial = await computer.screenshot() save_screenshot(initial.screenshot, "initial") - apps_block = "\n".join(f" - {u}" for u in app_urls) or " (none)" - # Seed the model with an initial screenshot + the env URLs so it knows - # exactly where it can navigate (no guessing localhost or external sites). - seed_text = ( - f"You are driving a Chromium browser ({DISPLAY_WIDTH}x{DISPLAY_HEIGHT}, " - f"absolute pixel coordinates) via the `computer` tool. The browser is " - f"currently at the env root: {app_url}\n" - f"\n" - f"Available app URLs (and the only URLs you should navigate to):\n" - f"{apps_block}\n" - f"\n" - f"Initial screenshot:" - ) messages.append( { "role": "user", - "content": [ - {"type": "text", "text": seed_text}, - screenshot_to_block(initial), - ], + "content": [screenshot_to_block(initial)], } ) From adfbdf09f5d7a0349260b65feded32f8c79a776f Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:43:10 -0700 Subject: [PATCH 13/17] Update browser_harness.py --- browser_harness.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/browser_harness.py b/browser_harness.py index 3e36e153..1b6e0887 100644 --- a/browser_harness.py +++ b/browser_harness.py @@ -28,10 +28,6 @@ DISPLAY_WIDTH = 1366 DISPLAY_HEIGHT = 768 -# Action set Claude can pass via the `computer` function tool. Mirrors the -# action vocabulary that theseus's browser-lease fallback exposes (see -# orchestrator/temporal/activities.py::BROWSER_LEASE_COMPUTER_ACTIONS) so the -# behaviour matches what Claude has been prompted on in production. SUPPORTED_ACTIONS = ( "navigate", "screenshot", @@ -191,10 +187,6 @@ def to_openai_conversation( # Playwright browser # --------------------------------------------------------------------------- -# Map Anthropic / xdotool key names to Playwright key names. -# Anthropic's `key` action uses xdotool syntax: e.g. "Return", "ctrl+s", -# "Page_Down", "shift+Tab". We split on '+', map each token, and feed it back -# to Playwright. Unknown tokens (single chars like 'a') pass through. PLAYWRIGHT_KEY_MAP = { "return": "Enter", "enter": "Enter", @@ -208,9 +200,6 @@ def to_openai_conversation( "shift": "Shift", "shift_l": "Shift", "shift_r": "Shift", - # `ControlOrMeta` is Playwright's portability shim: Cmd on macOS, Control - # elsewhere. Matches what gemini_harness uses and what most web apps treat - # as the same shortcut. Works in `keyboard.down/up/press` too. "ctrl": "ControlOrMeta", "control": "ControlOrMeta", "control_l": "ControlOrMeta", @@ -298,9 +287,6 @@ def __init__( self._screen_size = screen_size self._headless = headless self._highlight_mouse = highlight_mouse - # Lower-cased hostnames the model is allowed to navigate to. Anything - # else gets rejected so a hallucinated `localhost:3000` doesn't fly the - # browser into chrome-error://. self._allowed_hosts = ( {h.lower() for h in allowed_hosts} if allowed_hosts else None ) @@ -833,7 +819,7 @@ async def main(): print("\nAssistant: ", end="", flush=True) async with client.messages.stream( model=MODEL, - max_tokens=8192, + max_tokens=128000, messages=messages, tools=tools, system=system, From b18ea5fa8263d8c0e795b72739ec72fd885c03c6 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:46:07 -0700 Subject: [PATCH 14/17] Update claw_harness.py --- claw_harness.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/claw_harness.py b/claw_harness.py index 45e64c83..7b12a9a0 100644 --- a/claw_harness.py +++ b/claw_harness.py @@ -48,18 +48,6 @@ def convert_tool_format(tool: Tool) -> ToolParam: } -# ─────────────────── OpenClaw skills + memory integration ─────────────────── -# -# Mirrors what `theseus/orchestrator/temporal` does for tasks that ship an -# `INSTANCE_SKILLS_ROOT` and/or `INSTANCE_MEMORY_ROOT` env var. We talk to the -# env's runner FS directly over HTTP (`/fs/list`, `/fs/file/text`) and inject -# skill descriptions + memory contents into the system prompt, plus three -# on-demand tools (`read_skill`, `memory_get`, `memory_search`). - - -# Both prefaces are copied verbatim from theseus's -# `build_skills_system_prompt_section` / `download_memory_bundle_content` -# so the system prompt is byte-for-byte identical to the orchestrator's. SKILLS_PROMPT_PREFACE = ( "At its core, a skill is a folder containing a SKILL.md file. This file " "includes metadata (name and description, at minimum) and instructions " @@ -166,8 +154,6 @@ async def load_openclaw_skills( ) -> List[Dict[str, str]]: """One-level walk of skills_root. Each subfolder with a SKILL.md becomes a skill.""" skills_root = skills_root.rstrip("/") - # Retry: the runner FS often 502s for the first few seconds after MCP - # comes up. 4 attempts at 1.5s spacing covers the warmup window. last_error: Optional[BaseException] = None for attempt in range(4): try: @@ -192,7 +178,6 @@ async def load_openclaw_skills( except Exception: continue if not content or content.lstrip().startswith(" Optional[str]: - # Retry: the runner FS often 502s during the first few seconds after - # MCP becomes ready (theseus sees the same thing). Without retries, - # MEMORY.md / today / yesterday silently come back empty even though - # the file exists, and the model has to discover them via memory_get. - # 4 attempts at 1.5s spacing covers the warmup window. last_error: Optional[BaseException] = None for attempt in range(4): try: @@ -296,9 +276,6 @@ def build_openclaw_system_text( or memory.get("yesterday_md") or memory.get("files") ): - # Theseus joins memory sections with `\n\n---\n\n` and does NOT add a - # trailing `\n` to each section — matching that exactly avoids the - # extra blank line at EOF that diff -u flags as the only mismatch. sections = [f"## Memory System\n\n{MEMORY_PROMPT_PREFACE}"] if memory.get("memory_md"): sections.append(f"## Memory\n\n{memory['memory_md']}") @@ -558,10 +535,6 @@ async def main(): openclaw_active = bool(skills_root or memory_root or fs_root or current_date) if openclaw_active: - # Per the openclaw spec, runner_api_url piggy-backs on the first MCP URL: - # strip "/mcp", append "/api/v1/env". Theseus's `_derive_runner_api_url` - # uses the same construction for multi-app envs (per-app prefix is fine — - # the runner proxies through it). first_mcp_url = endpoints[0][1] runner_api_url = first_mcp_url[: -len("/mcp")] + "/api/v1/env" runner_fs = RunnerFs(runner_api_url) @@ -594,8 +567,6 @@ async def main(): if current_date: system_text += f"\n\nToday's date is {current_date}." if fs_root and runner_api_url: - # Mirrors theseus's `get_runner_bash_system_prompt(fs_root)` exactly so - # the agent gets the same input/deliverable contract. system_text += ( "\n\nYou have a bash tool (runner_bash__bash) that runs shell " "commands inside the environment container.\n\n" @@ -664,13 +635,6 @@ async def handler(input: Dict[str, Any]) -> str: anthropic_tools.append(tool_param) dispatch[tool_param["name"]] = make_mcp_handler(session, mcp_tool.name) - # OpenClaw on-demand tools. - # All descriptions and behavior are ported verbatim from theseus's - # `build_*_tool_definition` + `*_from_fs` helpers in - # `orchestrator/temporal/skill_memory_utils.py` so every tool's - # surface (name, description, parameter copy, return shape, error - # strings) is byte-for-byte identical. - if skills and runner_fs is not None and skills_root: skill_choices = [s["name"] for s in skills] anthropic_tools.append( From 5433b7b45c59f24eb0f5394661427fc775cd3678 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:47:24 -0700 Subject: [PATCH 15/17] Create requirements.txt --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..5afe30c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fleet-python==0.2.126 +playwright==1.57.0 +anthropic==0.100.0 +mcp==1.24.0 \ No newline at end of file From 9442ab8be1629e0ba4c87f1bff68c7a20d236a45 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:48:24 -0700 Subject: [PATCH 16/17] harnessess --- browser_harness.py | 971 --------------------------------------------- claw_harness.py | 968 -------------------------------------------- fleet_harness.py | 364 ----------------- requirements.txt | 4 - 4 files changed, 2307 deletions(-) delete mode 100644 browser_harness.py delete mode 100644 claw_harness.py delete mode 100644 fleet_harness.py delete mode 100644 requirements.txt diff --git a/browser_harness.py b/browser_harness.py deleted file mode 100644 index 1b6e0887..00000000 --- a/browser_harness.py +++ /dev/null @@ -1,971 +0,0 @@ -import ast -import asyncio -import base64 -import json -from datetime import datetime -from typing import Any, List, Literal, Optional - -import fleet -import playwright.async_api -import pydantic -from anthropic import AsyncAnthropic -from anthropic.types import ( - MessageParam, - TextBlockParam, - ToolParam, - ToolResultBlockParam, -) -from dotenv import load_dotenv -from playwright.async_api import async_playwright - -load_dotenv() - - -client = AsyncAnthropic() - - -MODEL = "claude-opus-4-7" -DISPLAY_WIDTH = 1366 -DISPLAY_HEIGHT = 768 - -SUPPORTED_ACTIONS = ( - "navigate", - "screenshot", - "left_click", - "right_click", - "double_click", - "triple_click", - "middle_click", - "mouse_move", - "left_click_drag", - "type", - "key", - "scroll", - "wait", - "cursor_position", - "left_mouse_down", - "left_mouse_up", - "hold_key", -) - - -def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filename = f"{prefix}_{timestamp}.{extension}" - filepath = f"/tmp/{filename}" - - with open(filepath, "w") as f: - f.write(content) - - return filepath - - -def save_screenshot(screenshot_bytes: bytes, prefix: str = "screenshot") -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filepath = f"/tmp/{prefix}_{timestamp}.png" - with open(filepath, "wb") as f: - f.write(screenshot_bytes) - return filepath - - -def _block_field(block: Any, key: str) -> Any: - if isinstance(block, dict): - return block.get(key) - return getattr(block, key, None) - - -def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: - """Return True if the verifier's top-level function declares a `conversation` param.""" - if not verifier_func: - return False - try: - tree = ast.parse(verifier_func) - except SyntaxError: - return False - - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - args = node.args - param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} - return "conversation" in param_names - return False - - -def to_openai_conversation( - system: List[TextBlockParam], - messages: List[MessageParam], -) -> List[dict]: - """Convert Anthropic-format system+messages into OpenAI chat schema. - - Same shape as fleet_harness.py's helper. Computer-use tool_use blocks - serialize their action dict into the `function.arguments` JSON string; - image-bearing tool_result blocks collapse to a `[screenshot]` placeholder - so the conversation stays text-only for verifiers. - """ - out: List[dict] = [] - - system_text = "\n\n".join( - b["text"] for b in system if b.get("type") == "text" and b.get("text") - ) - if system_text: - out.append({"role": "system", "content": system_text}) - - for msg in messages: - role = msg["role"] - content = msg["content"] - blocks = [content] if isinstance(content, str) else list(content) - - if role == "user": - text_parts: List[str] = [] - tool_msgs: List[dict] = [] - for block in blocks: - if isinstance(block, str): - text_parts.append(block) - continue - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_result": - tool_content = _block_field(block, "content") - if isinstance(tool_content, list): - rendered: List[str] = [] - for sub in tool_content: - sub_type = _block_field(sub, "type") - if sub_type == "text": - rendered.append(_block_field(sub, "text") or "") - elif sub_type == "image": - rendered.append("[screenshot]") - tool_content = "\n".join(rendered) if rendered else "" - elif not isinstance(tool_content, str): - tool_content = json.dumps(tool_content, default=str) - tool_msgs.append( - { - "role": "tool", - "tool_call_id": _block_field(block, "tool_use_id"), - "content": tool_content, - } - ) - - if text_parts: - out.append({"role": "user", "content": "\n".join(text_parts)}) - out.extend(tool_msgs) - continue - - if role == "assistant": - text_parts = [] - tool_calls: List[dict] = [] - for block in blocks: - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_use": - tool_calls.append( - { - "id": _block_field(block, "id"), - "type": "function", - "function": { - "name": _block_field(block, "name"), - "arguments": json.dumps( - _block_field(block, "input") or {} - ), - }, - } - ) - - entry: dict = { - "role": "assistant", - "content": "\n".join(text_parts) if text_parts else None, - } - if tool_calls: - entry["tool_calls"] = tool_calls - out.append(entry) - - return out - - -# --------------------------------------------------------------------------- -# Playwright browser -# --------------------------------------------------------------------------- - -PLAYWRIGHT_KEY_MAP = { - "return": "Enter", - "enter": "Enter", - "kp_enter": "Enter", - "tab": "Tab", - "backspace": "Backspace", - "delete": "Delete", - "escape": "Escape", - "esc": "Escape", - "space": "Space", - "shift": "Shift", - "shift_l": "Shift", - "shift_r": "Shift", - "ctrl": "ControlOrMeta", - "control": "ControlOrMeta", - "control_l": "ControlOrMeta", - "control_r": "ControlOrMeta", - "alt": "Alt", - "alt_l": "Alt", - "alt_r": "Alt", - "meta": "Meta", - "super": "Meta", - "super_l": "Meta", - "super_r": "Meta", - "cmd": "Meta", - "command": "Meta", - "win": "Meta", - "left": "ArrowLeft", - "right": "ArrowRight", - "up": "ArrowUp", - "down": "ArrowDown", - "page_up": "PageUp", - "pageup": "PageUp", - "page_down": "PageDown", - "pagedown": "PageDown", - "home": "Home", - "end": "End", - "insert": "Insert", - "caps_lock": "CapsLock", - "f1": "F1", - "f2": "F2", - "f3": "F3", - "f4": "F4", - "f5": "F5", - "f6": "F6", - "f7": "F7", - "f8": "F8", - "f9": "F9", - "f10": "F10", - "f11": "F11", - "f12": "F12", - "minus": "-", - "plus": "+", - "equal": "=", - "underscore": "_", - "slash": "/", - "backslash": "\\", - "semicolon": ";", - "apostrophe": "'", - "grave": "`", - "comma": ",", - "period": ".", - "bracketleft": "[", - "bracketright": "]", -} - - -def normalize_key(token: str) -> str: - return PLAYWRIGHT_KEY_MAP.get(token.lower(), token) - - -def parse_key_combo(text: str) -> List[str]: - """Parse an xdotool-style key string ('ctrl+shift+t') into Playwright keys.""" - return [normalize_key(t) for t in text.split("+") if t] - - -class EnvState(pydantic.BaseModel): - screenshot: bytes - url: str - - -class PlaywrightComputer: - """Local Playwright browser exposed through Claude computer-use semantics. - - Coordinates here are *absolute pixels* (no 0-1000 normalization), matching - Claude Opus 4.7's 1:1 pixel-to-coordinate behavior. - """ - - def __init__( - self, - screen_size: tuple[int, int], - initial_url: str, - headless: bool = True, - highlight_mouse: bool = False, - allowed_hosts: Optional[List[str]] = None, - ): - self._initial_url = initial_url - self._screen_size = screen_size - self._headless = headless - self._highlight_mouse = highlight_mouse - self._allowed_hosts = ( - {h.lower() for h in allowed_hosts} if allowed_hosts else None - ) - - async def _handle_new_page(self, new_page: playwright.async_api.Page): - """Computer use is single-tab; redirect new tabs into the main page.""" - new_url = new_page.url - await new_page.close() - if new_url and new_url != "about:blank": - await self._page.goto(new_url) - - async def __aenter__(self): - self._playwright = await async_playwright().start() - self._browser = await self._playwright.chromium.launch( - args=[ - "--disable-extensions", - "--disable-file-system", - "--disable-plugins", - "--disable-dev-shm-usage", - "--disable-background-networking", - "--disable-default-apps", - "--disable-sync", - ], - headless=self._headless, - ) - self._context = await self._browser.new_context( - viewport={ - "width": self._screen_size[0], - "height": self._screen_size[1], - } - ) - self._page = await self._context.new_page() - await self._page.goto(self._initial_url) - self._context.on("page", self._handle_new_page) - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if self._context: - await self._context.close() - try: - await self._browser.close() - except Exception as e: - if "Browser.close: Connection closed while reading from the driver" in str( - e - ): - pass - else: - raise - await self._playwright.stop() - - async def _safe_wait_for_load(self, timeout_ms: int = 5000): - try: - await self._page.wait_for_load_state(timeout=timeout_ms) - except playwright.async_api.TimeoutError: - pass - - async def screenshot(self) -> EnvState: - await self._safe_wait_for_load() - await asyncio.sleep(0.4) - png = await self._page.screenshot(type="png", full_page=False) - return EnvState(screenshot=png, url=self._page.url) - - async def highlight_mouse(self, x: int, y: int): - if not self._highlight_mouse: - return - await self._page.evaluate( - """([x, y]) => { - const div = document.createElement('div'); - div.style.pointerEvents = 'none'; - div.style.border = '4px solid red'; - div.style.borderRadius = '50%'; - div.style.width = '20px'; - div.style.height = '20px'; - div.style.position = 'fixed'; - div.style.zIndex = '999999'; - div.style.left = (x - 10) + 'px'; - div.style.top = (y - 10) + 'px'; - document.body.appendChild(div); - setTimeout(() => div.remove(), 1500); - }""", - [x, y], - ) - - async def _click( - self, - x: int, - y: int, - button: Literal["left", "right", "middle"] = "left", - click_count: int = 1, - modifiers: Optional[List[str]] = None, - ) -> EnvState: - await self.highlight_mouse(x, y) - # Playwright's raw `Mouse.click()` doesn't accept a `modifiers` kwarg - # (only the high-level Page.click does); hold them down manually around - # the click instead, mirroring how `scroll` does it. - held = list(modifiers or []) - for k in held: - await self._page.keyboard.down(k) - try: - await self._page.mouse.click(x, y, button=button, click_count=click_count) - finally: - for k in reversed(held): - await self._page.keyboard.up(k) - await self._safe_wait_for_load() - return await self.screenshot() - - async def left_click( - self, x: int, y: int, modifiers: Optional[List[str]] = None - ) -> EnvState: - return await self._click(x, y, "left", modifiers=modifiers) - - async def right_click( - self, x: int, y: int, modifiers: Optional[List[str]] = None - ) -> EnvState: - return await self._click(x, y, "right", modifiers=modifiers) - - async def middle_click( - self, x: int, y: int, modifiers: Optional[List[str]] = None - ) -> EnvState: - return await self._click(x, y, "middle", modifiers=modifiers) - - async def double_click( - self, x: int, y: int, modifiers: Optional[List[str]] = None - ) -> EnvState: - return await self._click(x, y, "left", click_count=2, modifiers=modifiers) - - async def triple_click( - self, x: int, y: int, modifiers: Optional[List[str]] = None - ) -> EnvState: - return await self._click(x, y, "left", click_count=3, modifiers=modifiers) - - async def mouse_move(self, x: int, y: int) -> EnvState: - await self.highlight_mouse(x, y) - await self._page.mouse.move(x, y) - return await self.screenshot() - - async def left_mouse_down(self, x: Optional[int], y: Optional[int]) -> EnvState: - if x is not None and y is not None: - await self._page.mouse.move(x, y) - await self._page.mouse.down(button="left") - return await self.screenshot() - - async def left_mouse_up(self, x: Optional[int], y: Optional[int]) -> EnvState: - if x is not None and y is not None: - await self._page.mouse.move(x, y) - await self._page.mouse.up(button="left") - return await self.screenshot() - - async def left_click_drag( - self, start: tuple[int, int], end: tuple[int, int] - ) -> EnvState: - await self.highlight_mouse(*start) - await self._page.mouse.move(*start) - await self._page.mouse.down() - await self.highlight_mouse(*end) - await self._page.mouse.move(*end, steps=20) - await self._page.mouse.up() - await self._safe_wait_for_load() - return await self.screenshot() - - async def type_text(self, text: str) -> EnvState: - await self._page.keyboard.type(text) - await self._safe_wait_for_load() - return await self.screenshot() - - async def key(self, text: str) -> EnvState: - keys = parse_key_combo(text) - if not keys: - return await self.screenshot() - for k in keys[:-1]: - await self._page.keyboard.down(k) - await self._page.keyboard.press(keys[-1]) - for k in reversed(keys[:-1]): - await self._page.keyboard.up(k) - await self._safe_wait_for_load() - return await self.screenshot() - - async def hold_key(self, text: str, duration: float) -> EnvState: - keys = parse_key_combo(text) - for k in keys: - await self._page.keyboard.down(k) - await asyncio.sleep(min(duration, 10.0)) - for k in reversed(keys): - await self._page.keyboard.up(k) - return await self.screenshot() - - async def scroll( - self, - x: int, - y: int, - direction: Literal["up", "down", "left", "right"], - amount: int, - modifiers: Optional[List[str]] = None, - ) -> EnvState: - await self._page.mouse.move(x, y) - # `scroll_amount` is in "clicks"; treat each as ~100px. - step = 100 * max(int(amount), 1) - dx, dy = 0, 0 - if direction == "up": - dy = -step - elif direction == "down": - dy = step - elif direction == "left": - dx = -step - elif direction == "right": - dx = step - modifiers = modifiers or [] - for k in modifiers: - await self._page.keyboard.down(k) - await self._page.mouse.wheel(dx, dy) - for k in reversed(modifiers): - await self._page.keyboard.up(k) - await self._safe_wait_for_load() - return await self.screenshot() - - async def wait(self, duration: float) -> EnvState: - await asyncio.sleep(min(duration, 30.0)) - return await self.screenshot() - - async def cursor_position(self) -> EnvState: - # Playwright doesn't expose mouse position; just return a screenshot. - return await self.screenshot() - - async def navigate(self, url: str) -> EnvState: - if not url.startswith(("http://", "https://", "about:", "data:")): - url = "https://" + url - if self._allowed_hosts is not None: - from urllib.parse import urlparse - - host = (urlparse(url).hostname or "").lower() - if host not in self._allowed_hosts: - raise RuntimeError( - f"navigate refused: host {host!r} is not in the env's " - f"allow-list {sorted(self._allowed_hosts)}. Stay on the app " - f"URLs you were given." - ) - await self._page.goto(url) - await self._safe_wait_for_load() - return await self.screenshot() - - -# --------------------------------------------------------------------------- -# Action dispatch -# --------------------------------------------------------------------------- - - -def _coord(value: Any) -> tuple[int, int]: - if value is None: - return 0, 0 - return int(value[0]), int(value[1]) - - -def _modifiers_from_text(text: Optional[str]) -> List[str]: - if not text: - return [] - return [normalize_key(t) for t in text.split("+") if t] - - -async def execute_computer_action( - computer: PlaywrightComputer, tool_input: dict -) -> tuple[EnvState | None, str | None, bool]: - """Run a computer-use tool call. - - Returns `(state, error_text, is_error)`. Exactly one of `state`/`error_text` - is set. Caller turns `state` into an image tool_result block; `error_text` - into a text tool_result with `is_error=True`. - """ - action = tool_input.get("action") - try: - if action == "screenshot": - return await computer.screenshot(), None, False - - if action == "navigate": - url = tool_input.get("url") or "" - if not url: - return None, "navigate requires a non-empty `url`", True - return await computer.navigate(url), None, False - - if action == "left_click": - x, y = _coord(tool_input.get("coordinate")) - return ( - await computer.left_click( - x, y, _modifiers_from_text(tool_input.get("text")) - ), - None, - False, - ) - - if action == "right_click": - x, y = _coord(tool_input.get("coordinate")) - return ( - await computer.right_click( - x, y, _modifiers_from_text(tool_input.get("text")) - ), - None, - False, - ) - - if action == "middle_click": - x, y = _coord(tool_input.get("coordinate")) - return ( - await computer.middle_click( - x, y, _modifiers_from_text(tool_input.get("text")) - ), - None, - False, - ) - - if action == "double_click": - x, y = _coord(tool_input.get("coordinate")) - return ( - await computer.double_click( - x, y, _modifiers_from_text(tool_input.get("text")) - ), - None, - False, - ) - - if action == "triple_click": - x, y = _coord(tool_input.get("coordinate")) - return ( - await computer.triple_click( - x, y, _modifiers_from_text(tool_input.get("text")) - ), - None, - False, - ) - - if action == "mouse_move": - x, y = _coord(tool_input.get("coordinate")) - return await computer.mouse_move(x, y), None, False - - if action == "left_mouse_down": - coord = tool_input.get("coordinate") - x, y = _coord(coord) if coord else (None, None) - return await computer.left_mouse_down(x, y), None, False - - if action == "left_mouse_up": - coord = tool_input.get("coordinate") - x, y = _coord(coord) if coord else (None, None) - return await computer.left_mouse_up(x, y), None, False - - if action == "left_click_drag": - start = _coord(tool_input.get("start_coordinate")) - end = _coord(tool_input.get("coordinate")) - return await computer.left_click_drag(start, end), None, False - - if action == "type": - text = tool_input.get("text") or "" - return await computer.type_text(text), None, False - - if action == "key": - text = tool_input.get("text") or "" - return await computer.key(text), None, False - - if action == "hold_key": - text = tool_input.get("text") or "" - duration = float(tool_input.get("duration", 1)) - return await computer.hold_key(text, duration), None, False - - if action == "scroll": - x, y = _coord(tool_input.get("coordinate")) - direction = tool_input.get("scroll_direction", "down") - amount = int(tool_input.get("scroll_amount", 3)) - return ( - await computer.scroll( - x, - y, - direction, - amount, - _modifiers_from_text(tool_input.get("text")), - ), - None, - False, - ) - - if action == "wait": - duration = float(tool_input.get("duration", 1)) - return await computer.wait(duration), None, False - - if action == "cursor_position": - return await computer.cursor_position(), None, False - - return None, f"Unsupported action: {action!r}", True - - except Exception as e: - return None, f"Action {action!r} failed: {type(e).__name__}: {e}", True - - -def screenshot_to_block(state: EnvState) -> dict: - return { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": base64.b64encode(state.screenshot).decode("utf-8"), - }, - } - - -# --------------------------------------------------------------------------- -# Main loop -# --------------------------------------------------------------------------- - - -async def main(): - tasks = await fleet.load_tasks_async( - keys=["task_intlycg3my7r_n_1769564666070_elnq81y45"] - ) - task = tasks[0] - - print("Task Key:", task.key) - print("Task Prompt:", task.prompt) - - env = await fleet.env.make_async( - env_key=task.env_key, - data_key=task.data_key, - env_variables=task.env_variables, - ttl_seconds=3600, - ) - print("Instance URL:", env.urls.root) - - computer_tool: ToolParam = { - "name": "computer", - "description": ( - f"Control a Chromium browser ({DISPLAY_WIDTH}x{DISPLAY_HEIGHT} viewport, " - "coordinates are absolute pixels from the top-left). Call with `action` " - "and the action-specific parameters. After every action you receive a " - "fresh screenshot back. Use `screenshot` whenever you need to re-observe " - "the page without otherwise changing state." - ), - "input_schema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": list(SUPPORTED_ACTIONS), - "description": "The browser action to perform.", - }, - "coordinate": { - "type": "array", - "items": {"type": "integer"}, - "minItems": 2, - "maxItems": 2, - "description": "[x, y] in absolute viewport pixels.", - }, - "start_coordinate": { - "type": "array", - "items": {"type": "integer"}, - "minItems": 2, - "maxItems": 2, - "description": "Drag start [x, y]; pair with `coordinate` as the end.", - }, - "text": { - "type": "string", - "description": ( - "For `type`: text to type. " - "For `key`/`hold_key`: xdotool key combo (e.g. 'Return', 'ctrl+a'). " - "For click/scroll: optional modifier (e.g. 'shift', 'ctrl')." - ), - }, - "url": { - "type": "string", - "description": "URL for the `navigate` action.", - }, - "scroll_direction": { - "type": "string", - "enum": ["up", "down", "left", "right"], - }, - "scroll_amount": { - "type": "integer", - "description": "Scroll wheel clicks (~100px each).", - }, - "duration": { - "type": "number", - "description": "Seconds for `wait`/`hold_key` (clamped to 30).", - }, - }, - "required": ["action"], - }, - "cache_control": {"type": "ephemeral"}, - } - tools: List[ToolParam] = [computer_tool] - - system: List[TextBlockParam] = [ - { - "type": "text", - "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", - "cache_control": {"type": "ephemeral"}, - } - ] - messages: List[MessageParam] = [ - { - "role": "user", - "content": [{"type": "text", "text": task.prompt}], - } - ] - - response = None - final_answer: str = "" - - from urllib.parse import urlparse - - root_host = (urlparse(env.urls.root).hostname or "").lower() - allowed_hosts = [root_host] if root_host else None - - async with PlaywrightComputer( - screen_size=(DISPLAY_WIDTH, DISPLAY_HEIGHT), - initial_url=env.urls.root, - headless=True, - allowed_hosts=allowed_hosts, - ) as computer: - initial = await computer.screenshot() - save_screenshot(initial.screenshot, "initial") - messages.append( - { - "role": "user", - "content": [screenshot_to_block(initial)], - } - ) - - turn = 0 - while True: - turn += 1 - print(f"\n{'=' * 50}") - print(f"Turn {turn} - sending {len(messages)} messages") - - messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} - - print("\nAssistant: ", end="", flush=True) - async with client.messages.stream( - model=MODEL, - max_tokens=128000, - messages=messages, - tools=tools, - system=system, - ) as stream: - async for text in stream.text_stream: - print(text, end="", flush=True) - response = await stream.get_final_message() - print() - - del messages[-1]["content"][-1]["cache_control"] - - usage = response.usage - print(f"Stop reason: {response.stop_reason}") - print( - f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " - f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " - f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" - ) - - messages.append({"role": "assistant", "content": response.content}) - - tool_results: List[ToolResultBlockParam] = [] - for block in response.content: - if block.type != "tool_use": - continue - - action = ( - block.input.get("action") if isinstance(block.input, dict) else None - ) - print(f"\nTool ({block.name}): action={action} input={block.input}") - - state, error_text, is_error = await execute_computer_action( - computer, dict(block.input) if isinstance(block.input, dict) else {} - ) - - if is_error or state is None: - print(f" -> error: {error_text}") - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": error_text or "Unknown error", - "is_error": True, - } - ) - continue - - screenshot_path = save_screenshot( - state.screenshot, f"turn{turn}_{action}" - ) - print(f" -> {state.url} | screenshot: {screenshot_path}") - - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": [screenshot_to_block(state)], - } - ) - - if not tool_results: - break - - messages.append({"role": "user", "content": tool_results}) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - transcript_filename = f"messages_cu_transcript_{timestamp}.json" - transcript_path = f"/tmp/{transcript_filename}" - - # Strip image data from the transcript so the JSON stays readable. - sanitized: List[dict] = [] - for msg in messages: - content = msg["content"] - if isinstance(content, str): - sanitized.append({"role": msg["role"], "content": content}) - continue - new_blocks = [] - for block in content: - btype = _block_field(block, "type") - if btype == "image": - new_blocks.append({"type": "image", "source": "[omitted]"}) - elif btype == "tool_result": - inner = _block_field(block, "content") - if isinstance(inner, list): - rendered = [] - for sub in inner: - sub_type = _block_field(sub, "type") - if sub_type == "image": - rendered.append( - {"type": "image", "source": "[omitted]"} - ) - else: - rendered.append( - sub if isinstance(sub, dict) else dict(sub.__dict__) - ) - new_blocks.append( - { - "type": "tool_result", - "tool_use_id": _block_field(block, "tool_use_id"), - "content": rendered, - "is_error": _block_field(block, "is_error") or False, - } - ) - else: - new_blocks.append( - { - "type": "tool_result", - "tool_use_id": _block_field(block, "tool_use_id"), - "content": inner, - "is_error": _block_field(block, "is_error") or False, - } - ) - else: - new_blocks.append( - block if isinstance(block, dict) else dict(block.__dict__) - ) - sanitized.append({"role": msg["role"], "content": new_blocks}) - - with open(transcript_path, "w") as f: - json.dump(sanitized, f, indent=2, default=str) - print(f"\nFull transcript saved to: {transcript_path}") - - if response is not None: - for block in reversed(response.content): - if block.type == "text" and block.text: - final_answer = block.text - break - - print(f"\nFinal Answer: {final_answer}") - - verify_kwargs: dict = {"final_answer": final_answer} - if verifier_accepts_conversation(task.verifier_func): - verify_kwargs["conversation"] = json.dumps( - to_openai_conversation(system, messages) - ) - print("Verifier accepts `conversation` param; passing it as a JSON string.") - else: - print("Verifier does not accept `conversation` param; skipping.") - - result = await task.verify_detailed_async(env, **verify_kwargs) - print("Verifier error:", result.error) - print("Verifier stdout:", result.stdout) - print("Reward score:", result.result) - - await env.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/claw_harness.py b/claw_harness.py deleted file mode 100644 index 7b12a9a0..00000000 --- a/claw_harness.py +++ /dev/null @@ -1,968 +0,0 @@ -import ast -import asyncio -import json -import sqlite3 -from contextlib import AsyncExitStack -from datetime import datetime, timedelta -from typing import Any, Awaitable, Callable, Dict, List, Optional - -import fleet -import httpx -from dotenv import load_dotenv -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client -from mcp.types import Tool -from anthropic import AsyncAnthropic -from anthropic.types import ( - MessageParam, - TextBlockParam, - ToolParam, - ToolResultBlockParam, -) - -load_dotenv() - - -client = AsyncAnthropic() - - -MODEL = "claude-opus-4-7" - - -def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filename = f"{prefix}_{timestamp}.{extension}" - filepath = f"/tmp/{filename}" - - with open(filepath, "w") as f: - f.write(content) - - return filepath - - -def convert_tool_format(tool: Tool) -> ToolParam: - return { - "name": tool.name, - "description": tool.description or "", - "input_schema": tool.inputSchema, - } - - -SKILLS_PROMPT_PREFACE = ( - "At its core, a skill is a folder containing a SKILL.md file. This file " - "includes metadata (name and description, at minimum) and instructions " - "that tell you how to perform a specific task. Skills can also bundle " - "scripts, templates, and reference materials.\n" - "\n" - "You have access to the following skills. Use the `read_skill` tool to " - "load the full instructions for a skill before using it." -) - -MEMORY_PROMPT_PREFACE = ( - "- **MEMORY.md** — long-term memory. Durable facts, preferences, and " - "decisions. Loaded at the start of every DM session.\n" - "- **memory/YYYY-MM-DD.md** — daily notes. Running context and " - "observations. Today and yesterday's notes are loaded automatically." -) - - -class RunnerFs: - """Minimal client for the env runner's `/fs/list` and `/fs/file/text` routes. - - Same endpoints `theseus/orchestrator/temporal/runner_fs_tools.py` uses. - `list` normalizes the response so callers always see `{name, is_dir}` dicts. - """ - - # Runner's `FileInfo` returns `file_type`; older fixtures use `type`/`kind`. - _DIR_TYPES = frozenset({"dir", "directory", "folder"}) - - def __init__(self, base_url: str, timeout: float = 30.0): - self.base_url = base_url.rstrip("/") - self._client = httpx.AsyncClient(timeout=timeout) - - async def aclose(self) -> None: - await self._client.aclose() - - async def list(self, path: str) -> List[Dict[str, Any]]: - r = await self._client.get(f"{self.base_url}/fs/list", params={"path": path}) - r.raise_for_status() - data = r.json() - if isinstance(data, list): - raw = data - elif isinstance(data, dict): - raw = next( - ( - data[k] - for k in ("entries", "items", "files", "results", "children") - if isinstance(data.get(k), list) - ), - [], - ) - else: - raw = [] - - out: List[Dict[str, Any]] = [] - for entry in raw: - if not isinstance(entry, dict): - continue - name = ( - entry.get("name") - or entry.get("filename") - or entry.get("basename") - or ( - entry["path"].rsplit("/", 1)[-1] - if isinstance(entry.get("path"), str) - else None - ) - ) - if not name: - continue - t = entry.get("file_type") or entry.get("type") or entry.get("kind") - is_dir = (isinstance(t, str) and t.lower() in self._DIR_TYPES) or ( - entry.get("is_dir") is True - ) - out.append({"name": name, "is_dir": is_dir}) - return out - - async def read_text(self, path: str) -> str: - r = await self._client.post( - f"{self.base_url}/fs/file/text", json={"path": path} - ) - r.raise_for_status() - return r.text - - -def parse_skill_description(md_text: str) -> str: - """Return the first non-blank line that doesn't start with `#` or `---`. - - This matches theseus's naive `_extract_skill_description`: for SKILL.md - files with YAML frontmatter the first matching line is the `name:` field - inside the frontmatter, which is what the orchestrator's prompt shows - (e.g. `- **bash-scripting**: name: bash-scripting`). Skipping the - frontmatter would return the prose description below it instead and - break parity. - """ - for line in md_text.split("\n"): - s = line.strip() - if s and not s.startswith("#") and not s.startswith("---"): - return s - return "" - - -async def load_openclaw_skills( - runner: RunnerFs, skills_root: str -) -> List[Dict[str, str]]: - """One-level walk of skills_root. Each subfolder with a SKILL.md becomes a skill.""" - skills_root = skills_root.rstrip("/") - last_error: Optional[BaseException] = None - for attempt in range(4): - try: - entries = await runner.list(skills_root) - break - except Exception as e: - last_error = e - if attempt < 3: - await asyncio.sleep(1.5) - else: - print(f" Skills: failed to list {skills_root}: {last_error}") - return [] - - skills: List[Dict[str, str]] = [] - for entry in entries: - # Top-level SKILL.md is intentionally skipped per OpenClaw spec — - # every skill lives in `//SKILL.md`. - if not entry["is_dir"]: - continue - try: - content = await runner.read_text(f"{skills_root}/{entry['name']}/SKILL.md") - except Exception: - continue - if not content or content.lstrip().startswith(" Dict[str, Any]: - """Load MEMORY.md + today/yesterday daily notes + enumerate every .md path.""" - memory_root = memory_root.rstrip("/") - today = yesterday = "" - if current_date_iso: - try: - dt = datetime.fromisoformat(current_date_iso.replace("Z", "+00:00")) - today = dt.strftime("%Y-%m-%d") - yesterday = (dt - timedelta(days=1)).strftime("%Y-%m-%d") - except ValueError: - pass - - async def _try_read(path: str) -> Optional[str]: - last_error: Optional[BaseException] = None - for attempt in range(4): - try: - text = await runner.read_text(path) - except Exception as e: - last_error = e - if attempt < 3: - await asyncio.sleep(1.5) - continue - if not text or text.lstrip().startswith(" {type(last_error).__name__}: {last_error}" - ) - return None - - memory_md = await _try_read(f"{memory_root}/MEMORY.md") - today_md = await _try_read(f"{memory_root}/memory/{today}.md") if today else None - yesterday_md = ( - await _try_read(f"{memory_root}/memory/{yesterday}.md") if yesterday else None - ) - - # Enumerate every .md file under memory_root, descending up to 2 levels. - files: Dict[str, str] = {} - - async def _walk(rel_dir: str, depth: int) -> None: - if depth > 2: - return - abs_dir = memory_root if not rel_dir else f"{memory_root}/{rel_dir}" - try: - entries = await runner.list(abs_dir) - except Exception: - return - for entry in entries: - name = entry["name"] - new_rel = name if not rel_dir else f"{rel_dir}/{name}" - if entry["is_dir"]: - await _walk(new_rel, depth + 1) - elif name.lower().endswith(".md"): - files.setdefault(new_rel, "") # contents lazy-loaded on search - - await _walk("", 0) - - return { - "today": today, - "yesterday": yesterday, - "memory_md": memory_md, - "today_md": today_md, - "yesterday_md": yesterday_md, - "files": files, - } - - -def build_openclaw_system_text( - skills: List[Dict[str, str]], memory: Dict[str, Any] -) -> str: - """Render Available Skills + Memory System sections for the system prompt.""" - parts: List[str] = [] - - if skills: - bullets = "\n".join(f"- **{s['name']}**: {s['description']}" for s in skills) - parts.append( - f"\n\n## Available Skills\n\n{SKILLS_PROMPT_PREFACE}\n\n{bullets}\n" - ) - - if memory and ( - memory.get("memory_md") - or memory.get("today_md") - or memory.get("yesterday_md") - or memory.get("files") - ): - sections = [f"## Memory System\n\n{MEMORY_PROMPT_PREFACE}"] - if memory.get("memory_md"): - sections.append(f"## Memory\n\n{memory['memory_md']}") - if memory.get("today_md"): - sections.append( - f"## Today's notes ({memory['today']})\n\n{memory['today_md']}" - ) - if memory.get("yesterday_md"): - sections.append( - f"## Yesterday's notes ({memory['yesterday']})\n\n" - f"{memory['yesterday_md']}" - ) - parts.append("\n\n" + "\n\n---\n\n".join(sections)) - - return "".join(parts) - - -def memory_search_fts5(query: str, documents: List[tuple]) -> str: - """SQLite FTS5 BM25 ranked search across memory file contents. - - Mirrors theseus's `fts5_search` + `search_memory_from_fs` exactly: - - Tokens are double-quoted and internal quotes doubled per the FTS5 spec - (otherwise queries with colons / punctuation crash with `fts5: syntax - error near ":"`). - - Returns FULL file content for each ranked hit (top 10), not snippets. - - Result format: `### {filename} (relevance: {score:.2f})\\n{content}` - joined by `\\n\\n---\\n\\n`. Score is `-rank` (FTS5 rank is negative; - closer to 0 = better, so we negate for display). - - Empty query, no docs, or no matches all return the same string — - `"No matching memories found."` — matching theseus. - """ - if not documents: - return "No matching memories found." - safe_query = " ".join( - '"{}"'.format(tok.replace('"', '""')) for tok in query.split() if tok.strip() - ) - if not safe_query: - return "No matching memories found." - - conn = sqlite3.connect(":memory:") - try: - conn.execute( - "CREATE VIRTUAL TABLE memory_fts USING fts5(" - ' filename, content, tokenize="unicode61"' - ")" - ) - conn.executemany("INSERT INTO memory_fts VALUES (?, ?)", documents) - rows = conn.execute( - "SELECT filename, content, rank " - "FROM memory_fts WHERE content MATCH ? " - "ORDER BY rank LIMIT ?", - (safe_query, 10), - ).fetchall() - except sqlite3.OperationalError: - return "No matching memories found." - finally: - conn.close() - if not rows: - return "No matching memories found." - sections = [ - f"### {fn} (relevance: {-rank:.2f})\n{content}" for fn, content, rank in rows - ] - return "\n\n---\n\n".join(sections) - - -def _block_field(block: Any, key: str) -> Any: - if isinstance(block, dict): - return block.get(key) - return getattr(block, key, None) - - -def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: - """Return True if the verifier's top-level function declares a `conversation` param. - - The verifier is a Python source string (e.g. `def verify(env, final_answer=None, - conversation=None): ...`). We parse it with ast and inspect the first top-level - function's signature. Nested helper functions are intentionally ignored. - """ - if not verifier_func: - return False - try: - tree = ast.parse(verifier_func) - except SyntaxError: - return False - - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - args = node.args - param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} - return "conversation" in param_names - return False - - -def to_openai_conversation( - system: List[TextBlockParam], - messages: List[MessageParam], -) -> List[dict]: - """Convert Anthropic-format system+messages into OpenAI chat schema. - - This is the format Fleet verifiers expect for the `conversation` param: - - {"role": "system", "content": str} - - {"role": "user", "content": str} - - {"role": "assistant", "content": str | None, "tool_calls": [...]} - - {"role": "tool", "tool_call_id": str, "content": str} - """ - out: List[dict] = [] - - system_text = "\n\n".join( - b["text"] for b in system if b.get("type") == "text" and b.get("text") - ) - if system_text: - out.append({"role": "system", "content": system_text}) - - for msg in messages: - role = msg["role"] - content = msg["content"] - blocks = [content] if isinstance(content, str) else list(content) - - if role == "user": - text_parts: List[str] = [] - tool_msgs: List[dict] = [] - for block in blocks: - if isinstance(block, str): - text_parts.append(block) - continue - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_result": - tool_content = _block_field(block, "content") - if not isinstance(tool_content, str): - tool_content = json.dumps(tool_content, default=str) - tool_msgs.append( - { - "role": "tool", - "tool_call_id": _block_field(block, "tool_use_id"), - "content": tool_content, - } - ) - - if text_parts: - out.append({"role": "user", "content": "\n".join(text_parts)}) - out.extend(tool_msgs) - continue - - if role == "assistant": - text_parts = [] - tool_calls: List[dict] = [] - for block in blocks: - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_use": - tool_calls.append( - { - "id": _block_field(block, "id"), - "type": "function", - "function": { - "name": _block_field(block, "name"), - "arguments": json.dumps( - _block_field(block, "input") or {} - ), - }, - } - ) - - entry: dict = { - "role": "assistant", - "content": "\n".join(text_parts) if text_parts else None, - } - if tool_calls: - entry["tool_calls"] = tool_calls - out.append(entry) - - return out - - -async def wait_for_mcp( - mcp_url: str, timeout: float = 120.0, delay: float = 1.0 -) -> None: - deadline = asyncio.get_event_loop().time() + timeout - attempt = 0 - last_error: BaseException | None = None - while asyncio.get_event_loop().time() < deadline: - attempt += 1 - try: - async with streamable_http_client(mcp_url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - print(f"\rMCP ready after {attempt} attempt(s) ") - return - except BaseException as e: - last_error = e - err_name = type(e).__name__ - print( - f"\rWaiting for MCP (attempt {attempt}, {err_name})...", - end="", - flush=True, - ) - await asyncio.sleep(delay) - raise TimeoutError( - f"MCP did not become ready at {mcp_url} within {timeout}s (last error: {last_error})" - ) - - -async def main(): - tasks = await fleet.load_tasks_async( - keys=["task_s2ttghbmpwpy_n_1776197545848_lf64q819a_bash"] - ) - task = tasks[0] - - print("Task Key:", task.key) - print("Task Prompt:", task.prompt) - - env = await fleet.env.make_async( - env_key=task.env_key, - data_key=task.data_key, - env_variables=task.env_variables, - ttl_seconds=3600, - ) - print("Instance URL:", env.urls.root) - - if env.multi_env_list: - endpoints = [(app, f"{env.urls.root}{app}/mcp") for app in env.multi_env_list] - else: - endpoints = [(None, env.mcp.url)] - - print(f"MCP endpoints ({len(endpoints)}):") - for app_name, url in endpoints: - print(f" {app_name or '(root)'}: {url}") - - print(f"\nProbing {len(endpoints)} endpoint(s) in parallel...") - await asyncio.gather(*(wait_for_mcp(url) for _, url in endpoints)) - print(f"All {len(endpoints)} MCP endpoint(s) ready") - - print(f"App URL: {env.urls.app[0]}") - - # OpenClaw integration: detect via task env_variables and load skills/memory - # from the env's runner FS. No-op for plain tasks. - env_vars = task.env_variables or {} - skills_root = env_vars.get("INSTANCE_SKILLS_ROOT") - memory_root = env_vars.get("INSTANCE_MEMORY_ROOT") - current_date = env_vars.get("CURRENT_DATE") - fs_root = env_vars.get("INSTANCE_FILESYSTEM_ROOT") - print( - f"OpenClaw env vars: {sorted(k for k in env_vars if k.startswith(('INSTANCE_', 'CURRENT_')))}" - ) - - skills: List[Dict[str, str]] = [] - memory_data: Dict[str, Any] = {} - runner_fs: Optional[RunnerFs] = None - runner_api_url: Optional[str] = None - - openclaw_active = bool(skills_root or memory_root or fs_root or current_date) - if openclaw_active: - first_mcp_url = endpoints[0][1] - runner_api_url = first_mcp_url[: -len("/mcp")] + "/api/v1/env" - runner_fs = RunnerFs(runner_api_url) - print(f"Runner FS API: {runner_api_url}") - - if skills_root: - print(f"Loading skills from {skills_root}...") - skills = await load_openclaw_skills(runner_fs, skills_root) - print(f" Loaded {len(skills)} skills") - - if memory_root: - print(f"Loading memory from {memory_root}...") - memory_data = await load_openclaw_memory( - runner_fs, memory_root, current_date - ) - print( - f" MEMORY.md={'yes' if memory_data.get('memory_md') else 'no'}, " - f"today({memory_data.get('today') or '-'})=" - f"{'yes' if memory_data.get('today_md') else 'no'}, " - f"yesterday({memory_data.get('yesterday') or '-'})=" - f"{'yes' if memory_data.get('yesterday_md') else 'no'}, " - f"all .md={len(memory_data.get('files', {}))}" - ) - - system_text = ( - "You are a helpful agent. Complete the task. The session ends when you " - "stop calling tools. Avoid unnecessary actions, as side effects may be " - "graded as task failure." - ) - if current_date: - system_text += f"\n\nToday's date is {current_date}." - if fs_root and runner_api_url: - system_text += ( - "\n\nYou have a bash tool (runner_bash__bash) that runs shell " - "commands inside the environment container.\n\n" - f"- **Inputs** for this task live under {fs_root}. If the task " - 'refers to documents, data, or files you were "given," look there ' - f"first (`ls {fs_root}`, `find {fs_root} -type f`).\n" - "- **Deliverables** go under /root/artifacts/. The verifier reads " - "that directory — write final outputs there with `cp`, `tee`, " - 'heredocs, or `>` redirection. Example: `echo "..." > ' - "/root/artifacts/report.md`.\n" - "- Scratch files can go in /tmp.\n" - "- The working directory persists across calls (`cd` carries " - "over); env vars and aliases do not." - ) - system_text += build_openclaw_system_text(skills, memory_data) - - sys_path = save_to_tmp(system_text, prefix="system_prompt", extension="txt") - print(f"System prompt ({len(system_text)} chars) saved to: {sys_path}") - - system: List[TextBlockParam] = [ - { - "type": "text", - "text": system_text, - "cache_control": {"type": "ephemeral"}, - } - ] - messages: List[MessageParam] = [ - { - "role": "user", - "content": [{"type": "text", "text": task.prompt}], - } - ] - - async with AsyncExitStack() as stack: - anthropic_tools: List[ToolParam] = [] - # namespaced tool name -> async callable taking input dict, returning str - dispatch: Dict[str, Callable[[Dict[str, Any]], Awaitable[str]]] = {} - - def make_mcp_handler( - session: ClientSession, original_name: str - ) -> Callable[[Dict[str, Any]], Awaitable[str]]: - async def handler(input: Dict[str, Any]) -> str: - result = await session.call_tool(original_name, input) - return result.content[0].text - - return handler - - for app_name, url in endpoints: - read_stream, write_stream, _ = await stack.enter_async_context( - streamable_http_client(url) - ) - session = await stack.enter_async_context( - ClientSession(read_stream, write_stream) - ) - await session.initialize() - - tools_resp = await session.list_tools() - # Hyphens in app names (e.g. "google-maps") aren't valid in OpenAI/Anthropic - # function names, so swap them for underscores. - prefix = f"{app_name.replace('-', '_')}__" if app_name else "" - - for mcp_tool in tools_resp.tools: - tool_param = convert_tool_format(mcp_tool) - if prefix: - tool_param["name"] = f"{prefix}{mcp_tool.name}" - anthropic_tools.append(tool_param) - dispatch[tool_param["name"]] = make_mcp_handler(session, mcp_tool.name) - - if skills and runner_fs is not None and skills_root: - skill_choices = [s["name"] for s in skills] - anthropic_tools.append( - { - "name": "read_skill", - "description": ( - "Load the full instructions for a skill. Call this " - "before using a skill to get detailed instructions, " - "code examples, and reference materials." - ), - "input_schema": { - "type": "object", - "properties": { - "skill_name": { - "type": "string", - "description": ( - "Name of the skill to load. " - f"Available: {', '.join(skill_choices)}" - ), - } - }, - "required": ["skill_name"], - }, - } - ) - - skills_root_clean = skills_root.rstrip("/") - - async def read_skill_handler(input: Dict[str, Any]) -> str: - # Mirrors `read_skill_from_fs`: re-read SKILL.md from the - # runner each call (no caching). - skill_name = input.get("skill_name", "") - abs_path = f"{skills_root_clean}/{skill_name}/SKILL.md" - try: - return await runner_fs.read_text(abs_path) - except Exception as exc: - return f"Error: skill '{skill_name}' not found at {abs_path}: {exc}" - - dispatch["read_skill"] = read_skill_handler - - if memory_data and runner_fs is not None and memory_root: - memory_root_clean = memory_root.rstrip("/") - - anthropic_tools.append( - { - "name": "memory_get", - "description": ( - "Read a specific memory file by name. Use this to " - "access daily notes or other memory files." - ), - "input_schema": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "description": ( - "The memory filename to read (e.g., " - "'MEMORY.md', 'memory/2024-01-15.md')" - ), - } - }, - "required": ["filename"], - }, - } - ) - anthropic_tools.append( - { - "name": "memory_search", - "description": ( - "Search across memory files for relevant information. " - "Returns matching filenames and snippets." - ), - "input_schema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query to find relevant memories", - } - }, - "required": ["query"], - }, - } - ) - - async def memory_get_handler(input: Dict[str, Any]) -> str: - # Mirrors `read_memory_file_from_fs`: no pre-validation, just - # join the path and try to read it. Same error string. - filename = input.get("filename", "") - abs_path = f"{memory_root_clean}/{filename}" - try: - return await runner_fs.read_text(abs_path) - except Exception as exc: - return ( - f"Error: memory file '{filename}' not found at " - f"{abs_path}: {exc}" - ) - - async def memory_search_handler(input: Dict[str, Any]) -> str: - # Mirrors `search_memory_from_fs`: re-walk the memory root, - # re-read every .md file, then run FTS5. Theseus does this - # fresh on every call rather than relying on an indexed cache, - # so we do the same. - query = input.get("query", "") - - filenames: List[str] = [] - - async def _walk(rel_dir: str, depth: int) -> None: - if depth > 2: - return - abs_dir = ( - memory_root_clean - if not rel_dir - else f"{memory_root_clean}/{rel_dir}" - ) - try: - entries = await runner_fs.list(abs_dir) - except Exception: - return - for entry in entries: - name = entry["name"] - new_rel = name if not rel_dir else f"{rel_dir}/{name}" - if entry["is_dir"]: - await _walk(new_rel, depth + 1) - elif name.lower().endswith(".md"): - filenames.append(new_rel) - - await _walk("", 0) - if not filenames: - return "No matching memories found." - - documents: List[tuple] = [] - for fn in filenames: - try: - text = await runner_fs.read_text(f"{memory_root_clean}/{fn}") - except Exception: - continue - documents.append((fn, text)) - - return memory_search_fts5(query, documents) - - dispatch["memory_get"] = memory_get_handler - dispatch["memory_search"] = memory_search_handler - - if fs_root and runner_api_url: - # Mirrors theseus's `runner_bash__bash` tool exactly: POST to - # {runner_api_url}/bash with {command, timeout_ms}. The runner - # persists the working directory across calls but not env vars. - # Theseus raises on missing/empty command and on HTTP errors — - # we let those propagate; the outer dispatch loop turns them - # into a `Tool error (...)` string for the model. - bash_url = f"{runner_api_url.rstrip('/')}/bash" - DEFAULT_TIMEOUT_MS = 120_000 - MAX_TIMEOUT_MS = 600_000 - anthropic_tools.append( - { - "name": "runner_bash__bash", - "description": ( - "Execute a bash command inside the environment " - f"container and return its stdout, stderr, and exit " - f"code. The environment root is {fs_root}; write " - "deliverables to /root/artifacts/. Working directory " - "persists across calls; shell env vars do not." - ), - "input_schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute.", - }, - "timeout": { - "type": "integer", - "description": ( - "Max execution time in milliseconds. " - f"Default {DEFAULT_TIMEOUT_MS} (2 min), " - f"max {MAX_TIMEOUT_MS} (10 min)." - ), - }, - }, - "required": ["command"], - }, - } - ) - - async def bash_handler(input: Dict[str, Any]) -> str: - command = (input or {}).get("command") - if not isinstance(command, str) or not command: - raise ValueError("bash tool requires a non-empty `command` string") - timeout_ms = (input or {}).get("timeout", DEFAULT_TIMEOUT_MS) - if not isinstance(timeout_ms, int) or timeout_ms <= 0: - timeout_ms = DEFAULT_TIMEOUT_MS - timeout_ms = min(timeout_ms, MAX_TIMEOUT_MS) - http_timeout = httpx.Timeout( - connect=10.0, - read=(timeout_ms / 1000.0) + 30.0, - write=30.0, - pool=10.0, - ) - async with httpx.AsyncClient(timeout=http_timeout) as c: - r = await c.post( - bash_url, - json={"command": command, "timeout_ms": timeout_ms}, - ) - r.raise_for_status() - body = r.json() - return ( - body - if isinstance(body, str) - else json.dumps(body, ensure_ascii=False) - ) - - dispatch["runner_bash__bash"] = bash_handler - - if anthropic_tools: - anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} - - openclaw_tools = [ - n - for n in dispatch - if n in {"read_skill", "memory_get", "memory_search", "runner_bash__bash"} - ] - print( - f"Loaded {len(anthropic_tools)} tools " - f"({len(anthropic_tools) - len(openclaw_tools)} from {len(endpoints)} MCP endpoint(s), " - f"{len(openclaw_tools)} OpenClaw: {openclaw_tools or '-'})" - ) - - while True: - print(f"\nSending {len(messages)} messages") - print([m["role"] for m in messages]) - - messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} - - print("\nAssistant: ", end="", flush=True) - async with client.messages.stream( - model=MODEL, - max_tokens=128000, - messages=messages, - tools=anthropic_tools, - system=system, - ) as stream: - async for text in stream.text_stream: - print(text, end="", flush=True) - response = await stream.get_final_message() - print() - - del messages[-1]["content"][-1]["cache_control"] - - usage = response.usage - print(f"Stop reason: {response.stop_reason}") - print( - f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " - f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " - f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" - ) - - messages.append({"role": "assistant", "content": response.content}) - - tool_results: List[ToolResultBlockParam] = [] - for block in response.content: - if block.type != "tool_use": - continue - - print(f"\nTool ({block.name}): {block.input}") - - handler = dispatch.get(block.name) - if handler is None: - result_str = f"Unknown tool: {block.name}" - else: - try: - result_str = await handler(block.input or {}) - except Exception as e: - result_str = f"Tool error ({type(e).__name__}): {e}" - - result_path = save_to_tmp( - result_str, prefix="tool_result", extension="txt" - ) - print(f"Tool result saved to: {result_path}") - - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": result_str, - } - ) - - if not tool_results: - break - - messages.append({"role": "user", "content": tool_results}) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - transcript_filename = f"messages_transcript_{timestamp}.json" - transcript_path = f"/tmp/{transcript_filename}" - - with open(transcript_path, "w") as f: - json.dump(messages, f, indent=2, default=str) - - print(f"\nFull transcript saved to: {transcript_path}") - - final_answer = response.content[-1].text - print(f" Final Answer: {final_answer}") - - verify_kwargs: dict = {"final_answer": final_answer} - if verifier_accepts_conversation(task.verifier_func): - # Per Theseus orchestrator's `pass_conversation_to_verifier` contract, - # `conversation` is a "JSON serialized format" string. Verifiers that want - # the list back can `json.loads(conversation)`; verifiers that just want to - # concatenate it into a prompt can do so without `str + list` errors. - verify_kwargs["conversation"] = json.dumps( - to_openai_conversation(system, messages) - ) - print("Verifier accepts `conversation` param; passing it as a JSON string.") - else: - print("Verifier does not accept `conversation` param; skipping.") - - result = await task.verify_detailed_async(env, **verify_kwargs) - print(f"Verifier error:", result.error) - print(f"Verifier stdout:", result.stdout) - print(f"Reward score:", result.result) - - if runner_fs is not None: - await runner_fs.aclose() - - await env.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/fleet_harness.py b/fleet_harness.py deleted file mode 100644 index 65fab86a..00000000 --- a/fleet_harness.py +++ /dev/null @@ -1,364 +0,0 @@ -import ast -import asyncio -import json -from contextlib import AsyncExitStack -from datetime import datetime -from typing import Any, List, Optional - -import fleet -from dotenv import load_dotenv -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client -from mcp.types import Tool -from anthropic import AsyncAnthropic -from anthropic.types import ( - MessageParam, - TextBlockParam, - ToolParam, - ToolResultBlockParam, -) - -load_dotenv() - - -client = AsyncAnthropic() - - -MODEL = "claude-opus-4-7" - - -def save_to_tmp(content: str, prefix: str = "output", extension: str = "txt") -> str: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - filename = f"{prefix}_{timestamp}.{extension}" - filepath = f"/tmp/{filename}" - - with open(filepath, "w") as f: - f.write(content) - - return filepath - - -def convert_tool_format(tool: Tool) -> ToolParam: - return { - "name": tool.name, - "description": tool.description or "", - "input_schema": tool.inputSchema, - } - - -def _block_field(block: Any, key: str) -> Any: - if isinstance(block, dict): - return block.get(key) - return getattr(block, key, None) - - -def verifier_accepts_conversation(verifier_func: Optional[str]) -> bool: - """Return True if the verifier's top-level function declares a `conversation` param. - - The verifier is a Python source string (e.g. `def verify(env, final_answer=None, - conversation=None): ...`). We parse it with ast and inspect the first top-level - function's signature. Nested helper functions are intentionally ignored. - """ - if not verifier_func: - return False - try: - tree = ast.parse(verifier_func) - except SyntaxError: - return False - - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - args = node.args - param_names = {a.arg for a in args.args} | {a.arg for a in args.kwonlyargs} - return "conversation" in param_names - return False - - -def to_openai_conversation( - system: List[TextBlockParam], - messages: List[MessageParam], -) -> List[dict]: - """Convert Anthropic-format system+messages into OpenAI chat schema. - - This is the format Fleet verifiers expect for the `conversation` param: - - {"role": "system", "content": str} - - {"role": "user", "content": str} - - {"role": "assistant", "content": str | None, "tool_calls": [...]} - - {"role": "tool", "tool_call_id": str, "content": str} - """ - out: List[dict] = [] - - system_text = "\n\n".join( - b["text"] for b in system if b.get("type") == "text" and b.get("text") - ) - if system_text: - out.append({"role": "system", "content": system_text}) - - for msg in messages: - role = msg["role"] - content = msg["content"] - blocks = [content] if isinstance(content, str) else list(content) - - if role == "user": - text_parts: List[str] = [] - tool_msgs: List[dict] = [] - for block in blocks: - if isinstance(block, str): - text_parts.append(block) - continue - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_result": - tool_content = _block_field(block, "content") - if not isinstance(tool_content, str): - tool_content = json.dumps(tool_content, default=str) - tool_msgs.append( - { - "role": "tool", - "tool_call_id": _block_field(block, "tool_use_id"), - "content": tool_content, - } - ) - - if text_parts: - out.append({"role": "user", "content": "\n".join(text_parts)}) - out.extend(tool_msgs) - continue - - if role == "assistant": - text_parts = [] - tool_calls: List[dict] = [] - for block in blocks: - btype = _block_field(block, "type") - if btype == "text": - text_parts.append(_block_field(block, "text") or "") - elif btype == "tool_use": - tool_calls.append( - { - "id": _block_field(block, "id"), - "type": "function", - "function": { - "name": _block_field(block, "name"), - "arguments": json.dumps( - _block_field(block, "input") or {} - ), - }, - } - ) - - entry: dict = { - "role": "assistant", - "content": "\n".join(text_parts) if text_parts else None, - } - if tool_calls: - entry["tool_calls"] = tool_calls - out.append(entry) - - return out - - -async def wait_for_mcp( - mcp_url: str, timeout: float = 120.0, delay: float = 1.0 -) -> None: - deadline = asyncio.get_event_loop().time() + timeout - attempt = 0 - last_error: BaseException | None = None - while asyncio.get_event_loop().time() < deadline: - attempt += 1 - try: - async with streamable_http_client(mcp_url) as ( - read_stream, - write_stream, - _, - ): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - print(f"\rMCP ready after {attempt} attempt(s) ") - return - except BaseException as e: - last_error = e - err_name = type(e).__name__ - print( - f"\rWaiting for MCP (attempt {attempt}, {err_name})...", - end="", - flush=True, - ) - await asyncio.sleep(delay) - raise TimeoutError( - f"MCP did not become ready at {mcp_url} within {timeout}s (last error: {last_error})" - ) - - -async def main(): - tasks = await fleet.load_tasks_async(project_key="bloomberg-sample-tasks") - task = tasks[0] - - print("Task Key:", task.key) - print("Task Prompt:", task.prompt) - - env = await fleet.env.make_async( - env_key=task.env_key, - data_key=task.data_key, - env_variables=task.env_variables, - ttl_seconds=3600, - ) - print("Instance URL:", env.urls.root) - - if env.multi_env_list: - endpoints = [(app, f"{env.urls.root}{app}/mcp") for app in env.multi_env_list] - else: - endpoints = [(None, env.mcp.url)] - - print(f"MCP endpoints ({len(endpoints)}):") - for app_name, url in endpoints: - print(f" {app_name or '(root)'}: {url}") - - print(f"\nProbing {len(endpoints)} endpoint(s) in parallel...") - await asyncio.gather(*(wait_for_mcp(url) for _, url in endpoints)) - print(f"All {len(endpoints)} MCP endpoint(s) ready") - - print(f"App URL: {env.urls.app[0]}") - - system: List[TextBlockParam] = [ - { - "type": "text", - "text": "You are a helpful agent. Complete the task. The session ends when you stop calling tools. Avoid unnecessary actions, as side effects may be graded as task failure.", - "cache_control": {"type": "ephemeral"}, - } - ] - messages: List[MessageParam] = [ - { - "role": "user", - "content": [{"type": "text", "text": task.prompt}], - } - ] - - async with AsyncExitStack() as stack: - anthropic_tools: List[ToolParam] = [] - # namespaced tool name -> (session, original mcp tool name) - dispatch: dict = {} - - for app_name, url in endpoints: - read_stream, write_stream, _ = await stack.enter_async_context( - streamable_http_client(url) - ) - session = await stack.enter_async_context( - ClientSession(read_stream, write_stream) - ) - await session.initialize() - - tools_resp = await session.list_tools() - # Hyphens in app names (e.g. "google-maps") aren't valid in OpenAI/Anthropic - # function names, so swap them for underscores. - prefix = f"{app_name.replace('-', '_')}__" if app_name else "" - - for mcp_tool in tools_resp.tools: - tool_param = convert_tool_format(mcp_tool) - if prefix: - tool_param["name"] = f"{prefix}{mcp_tool.name}" - anthropic_tools.append(tool_param) - dispatch[tool_param["name"]] = (session, mcp_tool.name) - - if anthropic_tools: - anthropic_tools[-1]["cache_control"] = {"type": "ephemeral"} - - print( - f"Loaded {len(anthropic_tools)} tools across {len(endpoints)} MCP endpoint(s)" - ) - - while True: - print(f"\nSending {len(messages)} messages") - print([m["role"] for m in messages]) - - messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"} - - print("\nAssistant: ", end="", flush=True) - async with client.messages.stream( - model=MODEL, - max_tokens=128000, - messages=messages, - tools=anthropic_tools, - system=system, - ) as stream: - async for text in stream.text_stream: - print(text, end="", flush=True) - response = await stream.get_final_message() - print() - - del messages[-1]["content"][-1]["cache_control"] - - usage = response.usage - print(f"Stop reason: {response.stop_reason}") - print( - f"Tokens: input={usage.input_tokens} output={usage.output_tokens} " - f"cache_read={getattr(usage, 'cache_read_input_tokens', 0)} " - f"cache_create={getattr(usage, 'cache_creation_input_tokens', 0)}" - ) - - messages.append({"role": "assistant", "content": response.content}) - - tool_results: List[ToolResultBlockParam] = [] - for block in response.content: - if block.type != "tool_use": - continue - - print(f"\nTool ({block.name}): {block.input}") - - target_session, original_name = dispatch[block.name] - result = await target_session.call_tool(original_name, block.input) - result_str = result.content[0].text - - result_path = save_to_tmp( - result_str, prefix="tool_result", extension="txt" - ) - print(f"Tool result saved to: {result_path}") - - tool_results.append( - { - "type": "tool_result", - "tool_use_id": block.id, - "content": result_str, - } - ) - - if not tool_results: - break - - messages.append({"role": "user", "content": tool_results}) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - transcript_filename = f"messages_transcript_{timestamp}.json" - transcript_path = f"/tmp/{transcript_filename}" - - with open(transcript_path, "w") as f: - json.dump(messages, f, indent=2, default=str) - - print(f"\nFull transcript saved to: {transcript_path}") - - final_answer = response.content[-1].text - print(f" Final Answer: {final_answer}") - - verify_kwargs: dict = {"final_answer": final_answer} - if verifier_accepts_conversation(task.verifier_func): - # Per Theseus orchestrator's `pass_conversation_to_verifier` contract, - # `conversation` is a "JSON serialized format" string. Verifiers that want - # the list back can `json.loads(conversation)`; verifiers that just want to - # concatenate it into a prompt can do so without `str + list` errors. - verify_kwargs["conversation"] = json.dumps( - to_openai_conversation(system, messages) - ) - print("Verifier accepts `conversation` param; passing it as a JSON string.") - else: - print("Verifier does not accept `conversation` param; skipping.") - - result = await task.verify_detailed_async(env, **verify_kwargs) - print(f"Verifier error:", result.error) - print(f"Verifier stdout:", result.stdout) - print(f"Reward score:", result.result) - - await env.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 5afe30c2..00000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -fleet-python==0.2.126 -playwright==1.57.0 -anthropic==0.100.0 -mcp==1.24.0 \ No newline at end of file From b8e9118e931f7f5809a169958d10e8d395a22a16 Mon Sep 17 00:00:00 2001 From: Gautham Elango Date: Fri, 8 May 2026 21:48:57 -0700 Subject: [PATCH 17/17] 0.2.126 --- fleet/__init__.py | 2 +- fleet/_async/__init__.py | 2 +- fleet/_async/base.py | 2 +- fleet/base.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fleet/__init__.py b/fleet/__init__.py index 9feeaf93..fc88e181 100644 --- a/fleet/__init__.py +++ b/fleet/__init__.py @@ -76,7 +76,7 @@ from . import global_client as _global_client from ._async import global_client as _async_global_client -__version__ = "0.2.125" +__version__ = "0.2.126" __all__ = [ # Core classes diff --git a/fleet/_async/__init__.py b/fleet/_async/__init__.py index bb8b7965..2f94672f 100644 --- a/fleet/_async/__init__.py +++ b/fleet/_async/__init__.py @@ -44,7 +44,7 @@ from .. import env from . import global_client as _async_global_client -__version__ = "0.2.125" +__version__ = "0.2.126" __all__ = [ # Core classes diff --git a/fleet/_async/base.py b/fleet/_async/base.py index 0b400208..67daae84 100644 --- a/fleet/_async/base.py +++ b/fleet/_async/base.py @@ -26,7 +26,7 @@ try: from .. import __version__ except ImportError: - __version__ = "0.2.125" + __version__ = "0.2.126" logger = logging.getLogger(__name__) diff --git a/fleet/base.py b/fleet/base.py index 919bce37..0cf7afbb 100644 --- a/fleet/base.py +++ b/fleet/base.py @@ -27,7 +27,7 @@ try: from . import __version__ except ImportError: - __version__ = "0.2.125" + __version__ = "0.2.126" logger = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index 34d1c4ac..91b9d1e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "fleet-python" -version = "0.2.125" +version = "0.2.126" description = "Python SDK for Fleet environments" authors = [ {name = "Fleet AI", email = "nic@fleet.so"}, diff --git a/uv.lock b/uv.lock index 2490bcbb..a9e234b3 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "fleet-python" -version = "0.2.125" +version = "0.2.126" source = { editable = "." } dependencies = [ { name = "aiohttp" },