Skip to content

Commit 9512f48

Browse files
committed
feat: Implement governance-aware fs tool execution and stub fallback
Add synthetic toolCall permission checks for fs/read_text_file and fs/write_text_file, centralize option resolution, and merge tool call updates into prompt results. Introduce a stub agent path when no external command is provided, exposing outputs via translator metadata and agent history. Expand translator metadata, expose .well-known/agent.json, add default auto-approval policies, refresh tests for the new behaviors, and tidy auxiliary tooling (Makefile run target, tool YAML heredocs).
1 parent 6ec35f5 commit 9512f48

9 files changed

Lines changed: 508 additions & 54 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,4 @@ clean:
5757

5858
run: dev-install
5959
@echo "--> Starting A2A-ACP Server..."
60-
./.venv/bin/uv run uvicorn src.a2a_acp.main:create_app --port $(or $(PORT),8000) --host "0.0.0.0" | jq
60+
./.venv/bin/uv run uvicorn src.a2a_acp.main:create_app --port $(or $(PORT),8000) --host "0.0.0.0"

auto_approval_policies.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
auto_approval_policies:
2+
- id: docs-edits
3+
applies_to: ["functions.acp_fs__write_text_file"]
4+
include_paths: ["docs/**", "*.md"]
5+
decision:
6+
type: approve
7+
optionId: approved
8+
reason: "Documentation edits auto-approved"
9+
- id: code-edits
10+
applies_to: ["functions.acp_fs__edit_text_file", "functions.acp_fs__multi_edit_text_file", "functions.acp_fs__write_text_file"]
11+
include_paths: ["src/**", "lib/**"]
12+
decision:
13+
type: approve
14+
optionId: approved
15+
reason: "Code edits auto-approved"

src/a2a/translator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
from copy import deepcopy
1112
from typing import Any, Dict, List, Optional
1213

1314
from .models import (
@@ -149,13 +150,19 @@ def zedacp_to_a2a_message(self, zedacp_response: Dict[str, Any],
149150
# Create A2A message parts
150151
parts: List[Any] = [TextPart(text=text_content)] if text_content else []
151152

153+
metadata: Dict[str, Any] = {"source": "zedacp"}
154+
if isinstance(zedacp_response, dict):
155+
tool_calls = zedacp_response.get("toolCalls")
156+
if isinstance(tool_calls, list) and tool_calls:
157+
metadata["toolCalls"] = deepcopy(tool_calls)
158+
152159
message = Message(
153160
role="agent",
154161
parts=parts,
155162
messageId=create_message_id(),
156163
taskId=task_id,
157164
contextId=context_id,
158-
metadata={"source": "zedacp"}
165+
metadata=metadata
159166
)
160167

161168
logger.debug("Converted ZedACP response to A2A message",

src/a2a_acp/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,7 @@ async def system_metrics(
12191219

12201220
# Well-known Agent Card discovery endpoint (publicly accessible)
12211221
@app.get("/.well-known/agent-card.json")
1222+
@app.get("/.well-known/agent.json")
12221223
async def get_agent_card_well_known(request: Request) -> Any:
12231224
"""Agent Card discovery endpoint for A2A protocol compatibility.
12241225
@@ -1234,6 +1235,7 @@ async def get_agent_card_well_known(request: Request) -> Any:
12341235

12351236
# A2A JSON-RPC endpoint - delegate to A2A server
12361237
@app.post("/a2a/rpc")
1238+
@app.post("/")
12371239
async def a2a_jsonrpc(
12381240
request: Request,
12391241
authorization: Optional[str] = Header(default=None)

src/a2a_acp/task_manager.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,14 @@ async def execute_task(
628628
async def permission_handler(request: ToolPermissionRequest) -> ToolPermissionDecision:
629629
return await self._handle_tool_permission(task_id, context, request)
630630

631+
# Provide a built-in stub agent when no external command is configured
632+
if not agent_command or agent_command == ["true"]:
633+
logger.info(
634+
"Executing task via built-in stub agent",
635+
extra={"task_id": task_id, "agent_command": agent_command},
636+
)
637+
return await self._execute_stub_agent(task_id, context, stream_handler)
638+
631639
# Execute via ZedACP agent
632640
async with ZedAgentConnection(
633641
agent_command,
@@ -788,6 +796,78 @@ async def on_chunk(text: str) -> None:
788796
await self._handle_task_error(task_id, task.status.state.value, e, "Unexpected error")
789797
raise
790798

799+
async def _execute_stub_agent(
800+
self,
801+
task_id: str,
802+
context: TaskExecutionContext,
803+
stream_handler: Optional[Callable[[str], Awaitable[None]]],
804+
) -> Task:
805+
"""Simulate agent execution when no external command is available."""
806+
chunk_text = "--END-OF-RESPONSE--"
807+
user_message = context.task.history[0] if context.task.history else None
808+
if user_message and user_message.parts:
809+
user_summary = self._extract_message_content(user_message).strip()
810+
if user_summary:
811+
chunk_text = f"{user_summary.strip()} --END-OF-RESPONSE--"
812+
813+
# Emit streaming chunk for clients
814+
if stream_handler:
815+
await stream_handler(chunk_text + " ")
816+
817+
from a2a.translator import A2ATranslator # Local import to avoid circular dependency
818+
819+
stub_response = {
820+
"stopReason": "end_turn",
821+
"result": {"text": chunk_text + " "},
822+
"toolCalls": [],
823+
}
824+
825+
translator = A2ATranslator()
826+
response_message = translator.zedacp_to_a2a_message(
827+
stub_response,
828+
context.task.contextId,
829+
task_id,
830+
)
831+
832+
# Tag stub output for auditing
833+
metadata = response_message.metadata or {}
834+
metadata["source"] = metadata.get("source", "zedacp")
835+
metadata["stubAgent"] = True
836+
response_message.metadata = metadata
837+
838+
async with context.lock:
839+
if context.task.history is None:
840+
context.task.history = []
841+
context.task.history.append(response_message)
842+
843+
previous_state = context.task.status.state.value
844+
context.task.status.state = TaskState.COMPLETED
845+
context.task.status.timestamp = current_timestamp()
846+
847+
asyncio.create_task(self._send_message_notification(task_id, response_message, "agent_response"))
848+
asyncio.create_task(
849+
self._send_task_notification(
850+
task_id,
851+
EventType.TASK_STATUS_CHANGE.value,
852+
{
853+
"old_state": previous_state,
854+
"new_state": TaskState.COMPLETED.value,
855+
"message": "Task execution completed via stub agent",
856+
},
857+
)
858+
)
859+
860+
logger.info(
861+
"Stub agent generated response",
862+
extra={
863+
"task_id": task_id,
864+
"chunk_length": len(chunk_text),
865+
"history_length": len(context.task.history or []),
866+
},
867+
)
868+
869+
return context.task
870+
791871
async def get_task(self, task_id: str) -> Optional[Task]:
792872
"""Retrieve a task by ID."""
793873
if task_id in self._active_tasks:

src/a2a_acp/tool_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ async def _check_for_updates(self, force_reload: bool = False) -> None:
292292
tools = await self._parse_tools_config(tools_config, str(path))
293293
updated_tools.update(tools)
294294
self._config_mtimes[config_path] = current_mtime
295-
logger.info(f"Loaded {len(tools)} tools from {config_path}")
295+
# logger.info(f"Loaded {len(tools)} tools from {config_path}")
296296

297297
except Exception as e:
298298
logger.error(f"Failed to load tools from {config_path}", extra={"error": str(e)})

0 commit comments

Comments
 (0)