Skip to content

Commit f013b69

Browse files
committed
feat: codex can now call tools. implemented functions.shell, functions.read_text_file and functions.write_text_file. Use A2A_TOOLS_CONFIG=tools.codex.yaml A2A_AGENT_COMMAND='./path/to/codex-acp/target/debug/codex-acp' make run to use codex-acp with tools
1 parent cda4172 commit f013b69

5 files changed

Lines changed: 440 additions & 5 deletions

File tree

src/a2a_acp/sandbox.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ async def validate_script_security(self, script: str, allowed_commands: Optional
321321
(r';\s*curl\s+', "Command chaining with curl (potential injection)"),
322322
(r'>\s*/dev/null', "Output redirection hiding (potential injection)"),
323323
(r'2>\s*/dev/null', "Error redirection hiding (potential injection)"),
324-
(r'\$\(', "Command substitution (potential injection)"),
324+
(r'\$\((?!\()', "Command substitution (potential injection)"),
325325
(r'`.*`', "Backtick command execution (potential injection)"),
326326
(r';\s*cat\s+', "Command chaining with cat (potential injection)"),
327327
(r'\|\s*cat\s+', "Pipe to cat (potential injection)"),
@@ -746,4 +746,4 @@ async def managed_sandbox(
746746
try:
747747
yield env, working_dir
748748
finally:
749-
await sandbox.cleanup_sandbox(working_dir, context)
749+
await sandbox.cleanup_sandbox(working_dir, context)

src/a2a_acp/tool_config.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,24 @@ def __init__(self, config_paths: Optional[List[str]] = None):
215215
config_paths: List of paths to search for tool configuration files.
216216
Defaults to standard locations.
217217
"""
218-
self.config_paths = config_paths or [
218+
default_paths = [
219219
"tools.yaml",
220220
"tools.yml",
221221
"config/tools.yaml",
222222
"config/tools.yml",
223223
"/etc/a2a-acp/tools.yaml"
224224
]
225+
base_paths = config_paths if config_paths is not None else default_paths
226+
227+
env_value = os.environ.get("A2A_TOOLS_CONFIG", "")
228+
env_paths = [p.strip() for p in env_value.split(os.pathsep) if p.strip()] if env_value else []
229+
230+
combined_paths: List[str] = []
231+
for path in env_paths + base_paths:
232+
if path not in combined_paths:
233+
combined_paths.append(path)
234+
235+
self.config_paths = combined_paths or default_paths
225236
self._tools: Dict[str, BashTool] = {}
226237
self._config_mtimes: Dict[str, float] = {}
227238
self._hot_reload_enabled = True
@@ -448,4 +459,4 @@ async def get_tool(tool_id: str) -> Optional[BashTool]:
448459
async def list_tools() -> List[BashTool]:
449460
"""Convenience function to list all available tools."""
450461
manager = get_tool_configuration_manager()
451-
return await manager.list_tools()
462+
return await manager.list_tools()

src/a2a_acp/zed_agent.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from asyncio import StreamReader, StreamWriter
77
from typing import Any, Awaitable, Callable, Coroutine, Optional, Sequence
8+
from uuid import uuid4
89

910
from .bash_executor import get_bash_executor, ToolExecutionResult
1011
from .sandbox import ExecutionContext
@@ -379,6 +380,12 @@ async def handler(payload: dict[str, Any]) -> None:
379380
"payload_keys": list(payload.keys())
380381
})
381382

383+
# Handle agent-initiated requests (e.g., fs/read_text_file)
384+
if payload.get("id") is not None and payload.get("method"):
385+
handled = await self._handle_agent_request(payload, session_id)
386+
if handled:
387+
return
388+
382389
if payload.get("method") == "session/update":
383390
params = payload.get("params", {})
384391
update_data = params.get("update", {})
@@ -580,6 +587,131 @@ def stderr(self) -> str:
580587
"""Return aggregated stderr output."""
581588
return "\n".join(self._stderr_buffer)
582589

590+
async def _handle_agent_request(self, payload: dict[str, Any], session_id: str) -> bool:
591+
"""Handle JSON-RPC requests initiated by the agent."""
592+
method = payload.get("method")
593+
594+
if method == "fs/read_text_file":
595+
await self._handle_fs_read_text_file(payload, session_id)
596+
return True
597+
598+
return False
599+
600+
async def _handle_fs_read_text_file(self, payload: dict[str, Any], session_id: str) -> None:
601+
"""Execute the Codex filesystem read request via the bash tool."""
602+
request_id = payload.get("id")
603+
params = payload.get("params") or {}
604+
605+
if request_id is None:
606+
self._logger.warning("fs/read_text_file request missing id", extra={"payload": payload})
607+
return
608+
609+
self._logger.info("Handling fs/read_text_file request", extra={
610+
"request_id": request_id,
611+
"path": params.get("path"),
612+
"line": params.get("line"),
613+
"limit": params.get("limit")
614+
})
615+
616+
# Resolve tool configuration
617+
tool = await get_tool("functions.acp_fs__read_text_file")
618+
if not tool:
619+
await self._write_json({
620+
"jsonrpc": "2.0",
621+
"id": request_id,
622+
"error": {
623+
"code": -32001,
624+
"message": "Tool functions.acp_fs__read_text_file is not available"
625+
}
626+
})
627+
return
628+
629+
tool_params: dict[str, Any] = {}
630+
path = params.get("path")
631+
if not path:
632+
await self._write_json({
633+
"jsonrpc": "2.0",
634+
"id": request_id,
635+
"error": {
636+
"code": -32602,
637+
"message": "Missing required parameter: path"
638+
}
639+
})
640+
return
641+
tool_params["path"] = path
642+
643+
# Optional parameters with validation
644+
if "line" in params and params["line"] is not None:
645+
try:
646+
tool_params["line"] = int(params["line"])
647+
except (TypeError, ValueError):
648+
await self._write_json({
649+
"jsonrpc": "2.0",
650+
"id": request_id,
651+
"error": {
652+
"code": -32602,
653+
"message": "Invalid line parameter; must be an integer"
654+
}
655+
})
656+
return
657+
658+
if "limit" in params and params["limit"] is not None:
659+
try:
660+
tool_params["limit"] = int(params["limit"])
661+
except (TypeError, ValueError):
662+
await self._write_json({
663+
"jsonrpc": "2.0",
664+
"id": request_id,
665+
"error": {
666+
"code": -32602,
667+
"message": "Invalid limit parameter; must be an integer"
668+
}
669+
})
670+
return
671+
672+
# Execute the tool
673+
executor = get_bash_executor()
674+
exec_session_id = params.get("sessionId") or session_id
675+
context = ExecutionContext(
676+
tool_id=tool.id,
677+
session_id=exec_session_id,
678+
task_id=f"fs_read_text_file_{uuid4().hex}",
679+
user_id="zedacp_user"
680+
)
681+
682+
result = await executor.execute_tool(tool, tool_params, context)
683+
684+
if result.success:
685+
await self._write_json({
686+
"jsonrpc": "2.0",
687+
"id": request_id,
688+
"result": {
689+
"content": result.output
690+
}
691+
})
692+
self._logger.info("fs/read_text_file completed", extra={
693+
"request_id": request_id,
694+
"path": path,
695+
"line": tool_params.get("line"),
696+
"limit": tool_params.get("limit"),
697+
"output_length": len(result.output) if result.output else 0
698+
})
699+
else:
700+
await self._write_json({
701+
"jsonrpc": "2.0",
702+
"id": request_id,
703+
"error": {
704+
"code": -32002,
705+
"message": result.error or "Tool execution failed"
706+
}
707+
})
708+
self._logger.error("fs/read_text_file failed", extra={
709+
"request_id": request_id,
710+
"path": path,
711+
"error": result.error,
712+
"return_code": result.return_code
713+
})
714+
583715
async def _process_zedacp_tool_calls(self, response: Dict[str, Any], session_id: str) -> Dict[str, Any]:
584716
"""Process ZedACP tool calls in agent response.
585717

0 commit comments

Comments
 (0)