diff --git a/pyproject.toml b/pyproject.toml index 3c1b8447..9ddde2c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,12 @@ a2a-grpc = [ a2a-redis = [ "redis>=5.0.0", ] +agui = [ + "ag-ui-protocol==0.1.20", + "a2a-sdk[http-server,signing]>=1.0.2,<2", + "starlette>=0.39.0", + "uvicorn[standard]>=0.30.0", +] diagram = ["termaid>=0.1; python_version >= '3.11'"] [dependency-groups] diff --git a/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md b/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md index 1a728e75..c6a61709 100644 --- a/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md +++ b/scripts/a2a/e2e/permission_wait/permission_wait_start_chat_prompt.md @@ -9,6 +9,13 @@ The runner substitutes `{run_id}`, `{stack_name}`, `{vswitch_name}`, and 请使用 alicloud-ros-agent Skill 的 {mode} 模式完成这个真实测试:在 cn-hangzhou 查询已有 VPC,选择其中一个, 只在该 VPC 内通过 ROS Stack 部署一个新 VSwitch。Stack 名称必须是 {stack_name},VSwitch 名称必须是 {vswitch_name},CIDR 不得与已有网段冲突。不要创建或删除 VPC,也不要修改其他资源。 +每次调用 Skill bridge 时,命令都必须显式设置 `ALICLOUD_ROS_AGENT_STATE_DIR={state_dir}`,不得使用其他状态目录。 +不要在 Qoder 本地自行执行云查询、生成模板或部署;第一条基础设施操作必须通过该 Skill 的 managed `start` +命令交给 ROS Agent,start 命令必须使用精确的小写参数 `--mode {mode_arg}`;之后只按 Skill 的 +`follow`、`continue` 或 `respond` 工作流处理同一个 job。 +ROS Agent 只使用任务工作区中的文件和已有工具完成任务,不要读取 iac-code 仓库源码或任务工作区外的路径。 +在 managed `start` 前可以按 Skill 要求执行一次 readiness `check`;check 完成后必须进入同一次测试的 start。 +整个测试只能执行一次 managed `start`,后续不得新建 job。 执行过程中请持续用简短文字解释当前阶段。只读云查询不应申请权限;任何非只读操作都必须等待我明确确认。 部署确认前必须展示部署摘要和 Mermaid 架构图。Pipeline 模式必须生成恰好两个都满足约束且确有差异的候选 @@ -25,6 +32,7 @@ step 开始/结束和候选选择,并在完成后保留同一 job 的 Normal h 不冲突网段的已有 VPC;只需保留该 VPC 的精简摘要,不要再次返回完整 VPC 列表。继续生成并校验只含一个 VSwitch 的 ROS Stack {stack_name},VSwitch 名称为 {vswitch_name}。在任何部署确认之前,先用简短说明和 Mermaid 架构图展示已有 VPC 与待建 VSwitch 的关系;确认和非只读云操作都必须等待我的明确回答。 +每次调用 Skill bridge 时,命令都必须显式设置 `ALICLOUD_ROS_AGENT_STATE_DIR={state_dir}`,不得使用其他状态目录。 ``` ## Confirm deployment @@ -32,6 +40,7 @@ Mermaid 架构图展示已有 VPC 与待建 VSwitch 的关系;确认和非只 ```text 我已经审阅刚才展示的部署摘要和 Mermaid 架构图,确认仅在所选已有 VPC 中通过 Stack {stack_name} 创建 VSwitch {vswitch_name}。请继续同一个 ROS Agent job;遇到非只读云权限时仍需把权限申请返回给我,不得替我批准。 +每次调用 Skill bridge 时,命令都必须显式设置 `ALICLOUD_ROS_AGENT_STATE_DIR={state_dir}`,不得使用其他状态目录。 ``` ## Cleanup @@ -41,6 +50,7 @@ VSwitch {vswitch_name}。请继续同一个 ROS Agent job;遇到非只读云 删除前说明目标并等待我确认;只允许删除这两个本次创建的对象,绝不能删除或修改已有 VPC。Pipeline 场景必须 复用 Pipeline handoff 的 Normal 会话,不得启动新的 Normal job。清理后用只读查询确认 Stack/VSwitch 已不存在, 并确认原有 VPC 仍可用。先展示精简删除摘要并等待我下一条明确确认。 +每次调用 Skill bridge 时,命令都必须显式设置 `ALICLOUD_ROS_AGENT_STATE_DIR={state_dir}`,不得使用其他状态目录。 ``` ## Confirm cleanup @@ -48,6 +58,7 @@ VSwitch {vswitch_name}。请继续同一个 ROS Agent job;遇到非只读云 ```text 我确认删除本次测试创建的 Stack {stack_name},并让其中的 VSwitch {vswitch_name} 随 Stack 删除。请继续同一个 ROS Agent job;不得删除或修改已有 VPC,遇到非只读云权限时仍需把权限申请返回给我,不得替我批准。 +每次调用 Skill bridge 时,命令都必须显式设置 `ALICLOUD_ROS_AGENT_STATE_DIR={state_dir}`,不得使用其他状态目录。 ``` ## Scripted answers diff --git a/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py b/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py index 96b90b4c..45d03496 100644 --- a/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py +++ b/scripts/a2a/e2e/permission_wait/run_start_chat_permission_wait.py @@ -439,6 +439,14 @@ def _run_qoder( resume: bool, run_dir: Path, ) -> dict[str, Any]: + state_dir = str(env.get("ALICLOUD_ROS_AGENT_STATE_DIR") or "") + driver_policy = ( + "You are driving one bounded ROS Agent E2E job. Execute at most one ros_agent.py bridge command in each " + "Qoder turn, then stop and return its bounded result. Across this session execute readiness check at most " + "once and managed start exactly once. After a job exists, never start another job; use only follow, continue, " + "or respond for that job. Prefix every ros_agent.py command with ALICLOUD_ROS_AGENT_STATE_DIR={}. Do not " + "replace the remote ROS Agent with local cloud, template, or deployment work." + ).format(state_dir) command = [ str(args.qoder_cli.expanduser().resolve()), "-p", @@ -447,6 +455,8 @@ def _run_qoder( "--config-dir", str(args.qoder_config_dir.expanduser().resolve()), "--dangerously-skip-permissions", + "--append-system-prompt", + driver_policy, "--cwd", str(workspace), ] @@ -473,6 +483,18 @@ def _run_qoder( content_block_index = 0 first_mermaid_block_index: int | None = None first_cloud_permission_block_index: int | None = None + bridge_command_count = 0 + bridge_managed_start = False + bridge_managed_start_count = 0 + bridge_check_count = 0 + bridge_state_dir_bound = False + bridge_state_dir_bound_count = 0 + bridge_start_shape_ok = False + bridge_script_path_kinds: set[str] = set() + bridge_tool_use_ids: set[str] = set() + bridge_result_codes: set[str] = set() + bridge_result_states: set[str] = set() + bridge_result_ok_values: set[bool] = set() def contains_cloud_permission(value: Any) -> bool: if isinstance(value, dict): @@ -511,6 +533,42 @@ def contains_cloud_permission(value: Any) -> bool: if not isinstance(block, dict): continue content_block_index += 1 + if block.get("type") == "tool_use": + serialized_block = json.dumps(block, ensure_ascii=False) + if "ros_agent.py" in serialized_block: + tool_use_id = block.get("id") + if isinstance(tool_use_id, str) and tool_use_id: + bridge_tool_use_ids.add(tool_use_id) + bridge_command_count += 1 + if " start " in serialized_block: + bridge_managed_start = True + bridge_managed_start_count += 1 + bridge_start_shape_ok = bridge_start_shape_ok or all( + token in serialized_block for token in ("--prompt-file", "--mode", "--follow") + ) + if " check" in serialized_block: + bridge_check_count += 1 + if "ALICLOUD_ROS_AGENT_STATE_DIR" in serialized_block: + bridge_state_dir_bound = True + bridge_state_dir_bound_count += 1 + if "" in serialized_block: + bridge_script_path_kinds.add("placeholder") + elif "/.qoderwork/skills/alicloud-ros-agent/" in serialized_block: + bridge_script_path_kinds.add("qoderwork") + elif "/.qoder/skills/alicloud-ros-agent/" in serialized_block: + bridge_script_path_kinds.add("qoder") + elif "/skills/alicloud-ros-agent/" in serialized_block: + bridge_script_path_kinds.add("repository") + else: + bridge_script_path_kinds.add("other") + if block.get("type") == "tool_result" and block.get("tool_use_id") in bridge_tool_use_ids: + serialized_result = json.dumps(block.get("content"), ensure_ascii=False) + for code in re.findall(r'"code"\s*:\s*"([A-Za-z0-9_.-]{1,80})"', serialized_result): + bridge_result_codes.add(code) + for state in re.findall(r'"state"\s*:\s*"([A-Za-z0-9_.-]{1,80})"', serialized_result): + bridge_result_states.add(state) + for raw_ok in re.findall(r'"ok"\s*:\s*(true|false)', serialized_result, re.IGNORECASE): + bridge_result_ok_values.add(raw_ok.casefold() == "true") if item.get("type") == "assistant" and block.get("type") == "text": text = block.get("text") if isinstance(text, str) and text.strip(): @@ -537,6 +595,17 @@ def contains_cloud_permission(value: Any) -> bool: "firstMermaidBlockIndex": first_mermaid_block_index, "firstCloudPermissionBlockIndex": first_cloud_permission_block_index, "mentionsFollow": " follow" in completed.stdout.casefold(), + "bridgeCommandCount": bridge_command_count, + "bridgeManagedStart": bridge_managed_start, + "bridgeManagedStartCount": bridge_managed_start_count, + "bridgeCheckCount": bridge_check_count, + "bridgeStateDirBound": bridge_state_dir_bound, + "bridgeStateDirBoundCount": bridge_state_dir_bound_count, + "bridgeStartShapeOk": bridge_start_shape_ok, + "bridgeScriptPathKinds": sorted(bridge_script_path_kinds), + "bridgeResultCodes": sorted(bridge_result_codes), + "bridgeResultStates": sorted(bridge_result_states), + "bridgeResultOkValues": sorted(bridge_result_ok_values), } _append_jsonl(run_dir / "qoder-turns.jsonl", evidence) if completed.returncode != 0: @@ -949,6 +1018,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: deployment_confirmation_attempts = 0 cleanup_confirmation_attempts = 0 architecture_seen = False + readiness_only_turn_seen = False job_path: Path | None = None skill_installation_backups: list[_SkillInstallationBackup] = [] read_only_cloud_evidence: list[dict[str, Any]] = [] @@ -959,6 +1029,8 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "stack_name": stack_name, "vswitch_name": vswitch_name, "mode": "Normal" if args.mode == "normal" else "Pipeline", + "mode_arg": args.mode, + "state_dir": str(state_root), }, ) try: @@ -985,8 +1057,25 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: resume=turn > 0, run_dir=run_dir, ) + bridge_command_count = int(qoder_evidence.get("bridgeCommandCount") or 0) + bridge_start_count = int(qoder_evidence.get("bridgeManagedStartCount") or 0) + if bridge_start_count and qoder_evidence.get("bridgeStateDirBound") is not True: + raise AssertionError("Qoder Skill bridge command did not bind the E2E state directory") architecture_seen = architecture_seen or bool(qoder_evidence.get("assistantMermaid")) jobs = _jobs(state_root) + if not jobs: + if bridge_command_count < 1: + raise AssertionError("Qoder did not execute a managed Skill bridge command") + bridge_check_count = int(qoder_evidence.get("bridgeCheckCount") or 0) + if readiness_only_turn_seen or bridge_check_count != bridge_command_count: + raise AssertionError("Qoder did not start the managed ROS Agent job after readiness") + readiness_only_turn_seen = True + next_prompt = ( + "readiness check 已完成。现在必须且只执行一次 alicloud-ros-agent Skill 的 managed start," + "使用精确的小写参数 --mode {} 开始之前给出的部署任务;不得再次 check,也不得在本地替代执行。" + "每条 bridge 命令必须显式设置 ALICLOUD_ROS_AGENT_STATE_DIR={}.".format(args.mode, state_root) + ) + continue if len(jobs) != 1: raise AssertionError("expected exactly one ROS Agent job, found {}".format(len(jobs))) job_path, job = jobs[0] @@ -1000,6 +1089,14 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: state = str(job.get("state") or "") if state in FAILURE_STATES: raise RuntimeError("ROS Agent job ended in {}".format(state)) + text_only_cleanup_summary = ( + cleanup_started + and state in TERMINAL_STATES + and int(job.get("turn") or 0) == cleanup_turn + and int(qoder_evidence.get("assistantTextBlocks") or 0) > 0 + ) + if bridge_command_count < 1 and not text_only_cleanup_summary: + raise AssertionError("Qoder did not execute a managed Skill bridge command") current = job.get("inputRequired") if state == "input-required" and isinstance(current, dict): input_id = current.get("inputId") @@ -1066,6 +1163,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "stack_name": stack_name, "vswitch_name": vswitch_name, "mode": args.mode, + "state_dir": str(state_root), }, ) else: @@ -1086,6 +1184,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "stack_name": stack_name, "vswitch_name": vswitch_name, "mode": args.mode, + "state_dir": str(state_root), }, ) else: @@ -1096,6 +1195,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "stack_name": stack_name, "vswitch_name": vswitch_name, "mode": args.mode, + "state_dir": str(state_root), }, ) continue @@ -1112,6 +1212,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "stack_name": stack_name, "vswitch_name": vswitch_name, "mode": args.mode, + "state_dir": str(state_root), }, ) continue @@ -1167,6 +1268,11 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: metrics_path = run_dir / "relay-metrics.json" if metrics_path.is_file(): relay_metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + relay_session_ids = { + str(item.get("sessionId")) + for item in relay_metrics.get("requests", []) + if item.get("action") == "StartChat" and item.get("sessionId") + } qoder_turns = [] qoder_path = run_dir / "qoder-turns.jsonl" if qoder_path.is_file(): @@ -1245,6 +1351,7 @@ def a2a_service(mode: str, config_path: Path, port: int) -> _Service: "native StartChat relay was used": any( item.get("action") == "StartChat" for item in relay_metrics.get("requests", []) ), + "all StartChat requests stayed in one ROS session": len(relay_session_ids) == 1, "Qoder emitted explanatory assistant text": sum( int(item.get("assistantTextBlocks") or 0) for item in qoder_turns ) diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py index 4b12045f..00502e48 100644 --- a/src/iac_code/a2a/app.py +++ b/src/iac_code/a2a/app.py @@ -39,9 +39,12 @@ resolve_a2a_public_path_roots, resolve_a2a_public_path_roots_for_data, ) +from iac_code.a2a.types import validate_protocol_id from iac_code.i18n import _ from iac_code.pipeline.config import get_run_mode from iac_code.services.configuration_readiness import configuration_readiness +from iac_code.services.session_backup import SessionBackupError +from iac_code.services.session_storage import SessionStorage logger = logging.getLogger(__name__) _V03_JSONRPC_METHODS = frozenset( @@ -477,6 +480,42 @@ async def get_agent_card(request: Request) -> Response: async def get_readiness(request: Request) -> JSONResponse: return JSONResponse(configuration_readiness(model=model)) + async def ensure_session_restored(request: Request) -> JSONResponse: + try: + payload = await request.json() + except Exception: + return JSONResponse({"error": "Invalid session restore request."}, status_code=400) + if not isinstance(payload, dict): + return JSONResponse({"error": "Invalid session restore request."}, status_code=400) + raw_cwd = payload.get("cwd") + raw_session_id = payload.get("sessionId") + if not isinstance(raw_cwd, str) or not raw_cwd or not isinstance(raw_session_id, str): + return JSONResponse({"error": "Invalid session restore request."}, status_code=400) + + executor = getattr(components.handler, "agent_executor", None) + resolve_cwd = getattr(executor, "_resolve_cwd", None) + backup_service = components.backup_service + if not callable(resolve_cwd) or backup_service is None: + return JSONResponse({"error": "Session restore is unavailable."}, status_code=503) + try: + session_id = validate_protocol_id(raw_session_id) + cwd = resolve_cwd({"iac_code": {"cwd": raw_cwd}}) + result = await asyncio.to_thread(backup_service.reconcile_session, cwd, session_id) + exists = SessionStorage().exists(cwd, session_id) + except ValueError: + return JSONResponse({"error": "Invalid session restore request."}, status_code=400) + except SessionBackupError as exc: + logger.warning("A2A session restore failed error_type=%s", type(exc).__name__) + return JSONResponse({"error": "Unable to restore the A2A session."}, status_code=503) + except Exception as exc: + logger.warning("A2A session restore failed error_type=%s", type(exc).__name__) + return JSONResponse({"error": "Unable to restore the A2A session."}, status_code=503) + + if not exists: + return JSONResponse({"status": "not_found"}, status_code=404) + status = "restored" if getattr(result, "action", None) == "restored" else "current" + return JSONResponse({"status": status}) + recovery_service = A2APipelineRecoveryService(task_store=components.task_store) async def recovery_path_roots(*, context_id: str | None, task_id: str | None) -> list[dict[str, str]]: @@ -523,6 +562,7 @@ async def get_pipeline_state(request: Request) -> JSONResponse: Route("/health", health, methods=["GET"]), Route(AGENT_CARD_WELL_KNOWN_PATH, get_agent_card, methods=["GET"]), Route("/iac-code/readiness", get_readiness, methods=["GET"]), + Route("/iac-code/session/ensure-restored", ensure_session_restored, methods=["POST"]), Route("/iac-code/pipeline/state", get_pipeline_state, methods=["GET"]), ] install_jsonrpc_error_data_passthrough() diff --git a/src/iac_code/a2a/client.py b/src/iac_code/a2a/client.py index 25262c25..0c719e61 100644 --- a/src/iac_code/a2a/client.py +++ b/src/iac_code/a2a/client.py @@ -154,22 +154,29 @@ async def send_message( *, cwd: str, context_id: str | None = None, + task_id: str | None = None, + message_id: str | None = None, model: str | None = None, iac_code_api_key: str | None = None, thinking_enabled: bool | None = None, thinking_effort: str | None = None, thinking_budget: int | None = None, + iac_code_metadata: Mapping[str, Any] | None = None, ) -> A2AClientResponse: payload = self._message_payload( method="SendMessage", prompt=prompt, + parts=None, cwd=cwd, context_id=context_id, + task_id=task_id, + message_id=message_id, model=model, iac_code_api_key=iac_code_api_key, thinking_enabled=thinking_enabled, thinking_effort=thinking_effort, thinking_budget=thinking_budget, + additional_iac_code_metadata=iac_code_metadata, ) transport = self._make_transport_client(url) response = await transport.send(payload) @@ -182,22 +189,101 @@ async def stream_message( *, cwd: str, context_id: str | None = None, + task_id: str | None = None, + message_id: str | None = None, model: str | None = None, iac_code_api_key: str | None = None, thinking_enabled: bool | None = None, thinking_effort: str | None = None, thinking_budget: int | None = None, + iac_code_metadata: Mapping[str, Any] | None = None, ) -> AsyncIterator[dict[str, Any]]: payload = self._message_payload( method="SendStreamingMessage", prompt=prompt, + parts=None, cwd=cwd, context_id=context_id, + task_id=task_id, + message_id=message_id, model=model, iac_code_api_key=iac_code_api_key, thinking_enabled=thinking_enabled, thinking_effort=thinking_effort, thinking_budget=thinking_budget, + additional_iac_code_metadata=iac_code_metadata, + ) + transport = self._make_transport_client(url) + async for event in transport.stream(payload): + yield event + + async def send_message_parts( + self, + url: str, + parts: Sequence[Mapping[str, Any]], + *, + cwd: str, + context_id: str, + task_id: str | None = None, + message_id: str | None = None, + model: str | None = None, + iac_code_api_key: str | None = None, + thinking_enabled: bool | None = None, + thinking_effort: str | None = None, + thinking_budget: int | None = None, + iac_code_metadata: Mapping[str, Any] | None = None, + ) -> A2AClientResponse: + """Send pre-validated A2A parts while preserving iac-code request metadata.""" + payload = self._message_payload( + method="SendMessage", + prompt=None, + parts=parts, + cwd=cwd, + context_id=context_id, + task_id=task_id, + message_id=message_id, + model=model, + iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, + additional_iac_code_metadata=iac_code_metadata, + ) + transport = self._make_transport_client(url) + response = await transport.send(payload) + return A2AClientResponse(payload=response) + + async def stream_message_parts( + self, + url: str, + parts: Sequence[Mapping[str, Any]], + *, + cwd: str, + context_id: str, + task_id: str | None = None, + message_id: str | None = None, + model: str | None = None, + iac_code_api_key: str | None = None, + thinking_enabled: bool | None = None, + thinking_effort: str | None = None, + thinking_budget: int | None = None, + iac_code_metadata: Mapping[str, Any] | None = None, + ) -> AsyncIterator[dict[str, Any]]: + """Stream a message whose parts were validated by an embedding protocol adapter.""" + payload = self._message_payload( + method="SendStreamingMessage", + prompt=None, + parts=parts, + cwd=cwd, + context_id=context_id, + task_id=task_id, + message_id=message_id, + model=model, + iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, + additional_iac_code_metadata=iac_code_metadata, ) transport = self._make_transport_client(url) async for event in transport.stream(payload): @@ -209,6 +295,43 @@ async def get_task(self, url: str, task_id: str, *, history_length: int | None = params["historyLength"] = history_length return await self._send_jsonrpc(url, method="GetTask", params=params) + async def get_pipeline_state( + self, + url: str, + *, + task_id: str, + after_sequence: int | None = None, + ) -> dict[str, Any] | None: + """Read iac-code's existing A2A pipeline recovery extension over HTTP.""" + + endpoint = url.rstrip("/") + "/iac-code/pipeline/state" + params = _without_none({"taskId": task_id, "afterSequence": after_sequence}) + response = await self._http_client.get(endpoint, params=params, headers=self._jsonrpc_headers()) + if response.status_code == 404: + return None + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError("A2A pipeline state response must be a JSON object") + return data + + async def ensure_session_restored(self, url: str, *, cwd: str, session_id: str) -> bool: + """Ensure the A2A session payload is locally available before a cold resume.""" + + endpoint = url.rstrip("/") + "/iac-code/session/ensure-restored" + response = await self._http_client.post( + endpoint, + json={"cwd": cwd, "sessionId": session_id}, + headers=self._jsonrpc_headers(), + ) + if response.status_code == 404: + return False + response.raise_for_status() + data = response.json() + if not isinstance(data, dict) or data.get("status") not in {"current", "restored"}: + raise ValueError("A2A session restore response must report a ready session") + return True + async def list_tasks( self, url: str, @@ -397,16 +520,23 @@ def _message_payload( self, *, method: str, - prompt: str, + prompt: str | None, + parts: Sequence[Mapping[str, Any]] | None, cwd: str, context_id: str | None, + task_id: str | None, + message_id: str | None, model: str | None, iac_code_api_key: str | None, thinking_enabled: bool | None, thinking_effort: str | None, thinking_budget: int | None, + additional_iac_code_metadata: Mapping[str, Any] | None, ) -> dict[str, Any]: - iac_code_metadata: dict[str, Any] = {"cwd": cwd} + if (prompt is None) == (parts is None): + raise ValueError("Exactly one of prompt or parts is required") + iac_code_metadata: dict[str, Any] = dict(additional_iac_code_metadata or {}) + iac_code_metadata["cwd"] = cwd if model: stripped_model = model.strip() if stripped_model: @@ -427,13 +557,15 @@ def _message_payload( if thinking: iac_code_metadata["thinking"] = thinking message: dict[str, Any] = { - "messageId": str(uuid.uuid4()), + "messageId": message_id or str(uuid.uuid4()), "role": "ROLE_USER", - "parts": [{"text": prompt}], + "parts": [{"text": prompt}] if prompt is not None else [dict(part) for part in parts or ()], "metadata": {"iac_code": iac_code_metadata}, } if context_id: message["contextId"] = context_id + if task_id: + message["taskId"] = task_id return self._jsonrpc_payload( method=method, params={ diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py index fa418155..79118855 100644 --- a/src/iac_code/a2a/events.py +++ b/src/iac_code/a2a/events.py @@ -82,7 +82,13 @@ def _truncate(value: Any, *, _depth: int = 0) -> Any: def _public_tool_input_metadata(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]: - return {"inputSummary": build_input_summary(tool_name, tool_input)} + return { + "inputSummary": build_input_summary(tool_name, tool_input), + # This is protocol data consumed as AG-UI TOOL_CALL_ARGS, not an audit + # record. Keep the canonical arguments here; the A2A wire boundary + # applies the existing path-only safe-mode projection to a copy. + "toolInput": copy.deepcopy(tool_input), + } def _public_permission_input_metadata(tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]: @@ -490,6 +496,20 @@ async def publish_stream_event( return None if isinstance(event, MessageEndEvent): + usage = { + "inputTokens": event.usage.input_tokens, + "outputTokens": event.usage.output_tokens, + "totalTokens": event.usage.total_tokens, + } + provider = getattr(event.usage, "provider", None) + model = getattr(event.usage, "model", None) + cached_input_tokens = getattr(event.usage, "cache_read_input_tokens", None) + if isinstance(provider, str) and provider: + usage["provider"] = provider + if isinstance(model, str) and model: + usage["model"] = model + if isinstance(cached_input_tokens, int) and not isinstance(cached_input_tokens, bool): + usage["cachedInputTokens"] = cached_input_tokens await _enqueue_status( event_queue, task_id=task_id, @@ -497,11 +517,7 @@ async def publish_stream_event( state=TaskState.TASK_STATE_WORKING, metadata={ "iac_code": { - "usage": { - "inputTokens": event.usage.input_tokens, - "outputTokens": event.usage.output_tokens, - "totalTokens": event.usage.total_tokens, - } + "usage": usage } }, iac_code_session_id=iac_code_session_id, diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 02cf6175..5d6b4129 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -60,6 +60,7 @@ ) from iac_code.a2a.pipeline_stream import BACKUP_COMMITTED_EVENT_TYPE, PipelineA2AEventPublisher from iac_code.a2a.projection import a2a_safe_mode_enabled +from iac_code.a2a.request_mode import resolve_request_run_mode from iac_code.a2a.runtime_overrides import ( a2a_request_context, configure_runtime_model, @@ -81,7 +82,7 @@ from iac_code.i18n import SUPPORTED_LANGUAGES, _ from iac_code.mcp.errors import MCPConnectionError from iac_code.mcp.prompt_dispatch import is_mcp_prompt_file_path -from iac_code.pipeline.config import RunMode, get_run_mode +from iac_code.pipeline.config import RunMode from iac_code.pipeline.constants import ( PIPELINE_EVENT_CLEANUP_COMPLETED, PIPELINE_EVENT_CLEANUP_FAILED, @@ -1196,7 +1197,7 @@ async def release_context_execution() -> None: ) cwd = self._resolve_cwd(metadata) public_path_roots = build_public_path_roots(cwd=cwd) - pipeline_mode = get_run_mode() == RunMode.PIPELINE + pipeline_mode = resolve_request_run_mode(metadata) == RunMode.PIPELINE if pipeline_mode and requested_task_id: reservation = await self._task_store.begin_context_execution_if_task_active( context_id, @@ -2041,11 +2042,12 @@ def make_pipeline_executor() -> IacCodeA2APipelineExecutor: raise InvalidParamsError(f"permission_resume_invalid: {exc}") from exc permission_audit = getattr(audit_event.permission_result, "audit", None) - principal_ref, region = permission_execution_identity( - tool_name=audit_event.tool_name, - tool_input=audit_event.tool_input, - permission_audit=permission_audit, - ) + with a2a_request_context(aliyun_credential=aliyun_credential): + principal_ref, region = permission_execution_identity( + tool_name=audit_event.tool_name, + tool_input=audit_event.tool_input, + permission_audit=permission_audit, + ) if principal_ref != record.get("principalRef") or region != record.get("region"): raise InvalidParamsError("permission_resume_invalid: cloud execution identity changed.") diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index 8ac74d62..4a7bc226 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -29,6 +29,7 @@ ContextUsageEvent, DiagramEvent, MCPProgressEvent, + MessageEndEvent, MessageStartEvent, PermissionRequestEvent, ResourceObservedEvent, @@ -263,6 +264,8 @@ def translate(self, event: Any) -> list[dict[str, Any]]: return self._translate_pipeline_event(event) if isinstance(event, MessageStartEvent): return [self._translate_parent_scoped_display_event("message_started", {"messageId": event.message_id})] + if isinstance(event, MessageEndEvent): + return [self._translate_parent_scoped_display_event("usage", _provider_usage_data(event.usage))] if isinstance(event, TextDeltaEvent): return [self._translate_text_delta_event(event)] if isinstance(event, ThinkingDeltaEvent): @@ -559,6 +562,11 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> data = {"messageId": inner.message_id} input_data = None permission = None + elif isinstance(inner, MessageEndEvent): + event_type = "usage" + data = _provider_usage_data(inner.usage) + input_data = None + permission = None elif isinstance(inner, TextDeltaEvent): event_type = "text_delta" data = {"text": inner.text} @@ -1456,6 +1464,17 @@ def _usage_data(usage: dict[str, Any]) -> dict[str, Any]: } +def _provider_usage_data(usage: Any) -> dict[str, Any]: + return { + "provider": getattr(usage, "provider", None), + "model": getattr(usage, "model", None), + "inputTokens": getattr(usage, "input_tokens", 0), + "outputTokens": getattr(usage, "output_tokens", 0), + "totalTokens": getattr(usage, "total_tokens", 0), + "cachedInputTokens": getattr(usage, "cache_read_input_tokens", 0), + } + + def _thinking_delta_data(event: ThinkingDeltaEvent) -> dict[str, Any]: return {"type": "raw_thinking", "text": _truncate(event.text)} diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index c3a3c60d..ddf9b7e9 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -1844,6 +1844,14 @@ def _unified_input_projection( projection["language"] = language if isinstance(deployment_summary, dict): projection["deploymentSummary"] = deployment_summary + scope = envelope.get("scope") + if isinstance(scope, str) and scope: + projection["scope"] = scope + candidate = envelope.get("candidate") + if isinstance(candidate, dict): + sub_pipeline_id = candidate.get("id") or candidate.get("subPipelineId") + if isinstance(sub_pipeline_id, str) and sub_pipeline_id: + projection["subPipelineId"] = sub_pipeline_id return projection raw_input = envelope.get("input") if not isinstance(raw_input, dict): @@ -1866,6 +1874,9 @@ def _unified_input_projection( )[:1000], "required": True, } + tool_use_id = raw_input.get("toolUseId") + if isinstance(tool_use_id, str) and tool_use_id: + projected["toolUseId"] = tool_use_id if kind == "ask_user_question": projected["allowFreeText"] = bool(raw_input.get("allowFreeText")) free_text_prompt = raw_input.get("freeTextPrompt") diff --git a/src/iac_code/a2a/request_mode.py b/src/iac_code/a2a/request_mode.py new file mode 100644 index 00000000..f741bc8b --- /dev/null +++ b/src/iac_code/a2a/request_mode.py @@ -0,0 +1,28 @@ +"""Resolve the A2A execution mode from request metadata.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from google.protobuf.json_format import MessageToDict + +from iac_code.pipeline.config import RunMode, get_run_mode + + +def resolve_request_run_mode(value: Any | None) -> RunMode: + """Use the internal request override, falling back to the server mode.""" + + metadata = getattr(value, "metadata", value) + if metadata is not None and hasattr(metadata, "DESCRIPTOR"): + metadata = MessageToDict(metadata, preserving_proto_field_name=False) + if isinstance(metadata, Mapping): + iac_code = metadata.get("iac_code") + if isinstance(iac_code, Mapping): + raw_mode = iac_code.get("run_mode") or iac_code.get("runMode") + if isinstance(raw_mode, str): + try: + return RunMode(raw_mode.strip().lower()) + except ValueError: + pass + return get_run_mode() diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index a5524a32..12712cb6 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -87,10 +87,11 @@ from iac_code.a2a.push_queue import LocalFileA2APushQueue, RedisStreamsA2APushQueue, require_redis_asyncio from iac_code.a2a.push_secrets import A2APushSecretKeyring from iac_code.a2a.push_worker import A2APushDeliveryWorker +from iac_code.a2a.request_mode import resolve_request_run_mode from iac_code.a2a.runtime_registry import A2ARuntimeOwner, A2ARuntimeRegistration, register_runtime_owner from iac_code.a2a.task_store import A2ATaskStore from iac_code.i18n import _ -from iac_code.pipeline.config import RunMode, get_run_mode +from iac_code.pipeline.config import RunMode from iac_code.services.permission_wait import PermissionWaitCheckpointStore from iac_code.services.session_backup import SessionBackupService from iac_code.services.session_backup_staging import ( @@ -284,6 +285,7 @@ class A2ARuntimeComponents: card: Any app: Starlette _exit_stack: AsyncExitStack + backup_service: Any | None = None push_worker: Any | None = None push_queue: Any | None = None runtime_registration: A2ARuntimeRegistration | None = None @@ -463,6 +465,7 @@ def create_runtime_components( card=card, app=app, _exit_stack=AsyncExitStack(), + backup_service=backup_service, push_worker=push_worker, push_queue=push_queue_instance, runtime_registration=runtime_registration, @@ -570,7 +573,7 @@ async def on_message_send_stream(self, params: SendMessageRequest, context): base_stream = super().on_message_send_stream(params, context) tracked_stream = ( base_stream - if get_run_mode() is RunMode.PIPELINE + if resolve_request_run_mode(params.message) is RunMode.PIPELINE else _iterate_with_pipeline_transport_tracking( base_stream, task_id=getattr(params.message, "task_id", None) or None, @@ -713,7 +716,9 @@ async def run_active_message() -> None: await self._cleanup_active_message_producer(producer_task, task.id) async def _hydrate_recoverable_pipeline_task_id(self, params: SendMessageRequest) -> None: - if get_run_mode() is not RunMode.PIPELINE or not isinstance(self.task_store, A2ATaskStore): + if resolve_request_run_mode(params.message) is not RunMode.PIPELINE or not isinstance( + self.task_store, A2ATaskStore + ): return message = getattr(params, "message", None) if message is None: @@ -741,7 +746,9 @@ async def _hydrate_recoverable_pipeline_task_id(self, params: SendMessageRequest message.task_id = task_id async def _reconcile_recoverable_pipeline_task(self, params: SendMessageRequest, context) -> None: - if get_run_mode() is not RunMode.PIPELINE or not isinstance(self.task_store, A2ATaskStore): + if resolve_request_run_mode(params.message) is not RunMode.PIPELINE or not isinstance( + self.task_store, A2ATaskStore + ): return message = getattr(params, "message", None) if message is None: @@ -1056,7 +1063,7 @@ async def on_delete_task_push_notification_config( await super().on_delete_task_push_notification_config(params, context) def _validate_pipeline_message_request(self, params: SendMessageRequest) -> None: - if get_run_mode() != RunMode.PIPELINE: + if resolve_request_run_mode(params.message) != RunMode.PIPELINE: return executor = getattr(self, "agent_executor", None) if isinstance(executor, IacCodeA2AExecutor): diff --git a/src/iac_code/agui/__init__.py b/src/iac_code/agui/__init__.py new file mode 100644 index 00000000..7092bd62 --- /dev/null +++ b/src/iac_code/agui/__init__.py @@ -0,0 +1 @@ +"""AG-UI protocol adapter backed exclusively by the iac-code A2A server.""" diff --git a/src/iac_code/agui/adapter.py b/src/iac_code/agui/adapter.py new file mode 100644 index 00000000..4f5dff75 --- /dev/null +++ b/src/iac_code/agui/adapter.py @@ -0,0 +1,1439 @@ +"""AG-UI run coordination implemented only in terms of the public A2A client.""" + +from __future__ import annotations + +import asyncio +import contextlib +import copy +import hashlib +import logging +import time +import uuid +from collections.abc import AsyncIterator, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ag_ui.core import ( + Interrupt, + RunAgentInput, + RunErrorEvent, + RunFinishedEvent, + RunFinishedInterruptOutcome, + RunFinishedSuccessOutcome, + RunStartedEvent, + TokenUsage, +) +from jsonschema import ValidationError as JsonSchemaValidationError +from jsonschema import validate as validate_json_schema + +from iac_code.a2a.client import A2AClient +from iac_code.agui.errors import AdmissionError, AguiError, normalize_agui_language, translate_agui_error +from iac_code.agui.events import ( + A2AEventMapper, + a2a_context_id, + a2a_iac_code_metadata, + a2a_iac_code_session_id, + a2a_inputs, + a2a_sideband_input_ids, + a2a_state, + a2a_task_id, + aggregate_usage, + interrupt_from_a2a, + resume_value, + timestamp_ms, +) +from iac_code.agui.inputs import ( + IacCodeForwardedProps, + canonical_digest, + latest_user_message, + parse_forwarded_props, + resolve_cwd, + validate_tools, +) +from iac_code.agui.state import ( + AGUI_STATE_SCHEMA_VERSION, + AguiStateStore, + AguiStateStoreError, + FileAguiThreadStateStore, +) + +logger = logging.getLogger(__name__) +_FAILED_STATES = frozenset({"auth-required", "failed", "rejected"}) +_SUCCESS_STATES = frozenset({"completed", "input-required"}) + + +@dataclass +class PendingInput: + value: dict[str, Any] + interrupt: Interrupt + sideband: bool = False + + +@dataclass +class ThreadBinding: + thread_id: str + context_id: str + cwd: str + user_id: str | None + ros_invocation_id: str + iac_code_session_id: str | None = None + execution_id: str = field(default_factory=lambda: str(uuid.uuid4())) + task_id: str | None = None + pending: dict[str, PendingInput] = field(default_factory=dict) + pipeline_sequence: int = 0 + pipeline_open_steps: set[str] = field(default_factory=set) + text_snapshot_digests: set[str] = field(default_factory=set) + run_digests: dict[str, str] = field(default_factory=dict) + applied_resume_digests: dict[tuple[str, str], str] = field(default_factory=dict) + terminal_execution_ids: set[str] = field(default_factory=set) + active_run_id: str | None = None + expiry_task: asyncio.Task[None] | None = None + + +@dataclass +class RunTicket: + run_input: RunAgentInput + binding: ThreadBinding + request_digest: str + is_resume: bool + preferred_language: str + local_task: asyncio.Task[Any] | None = None + completed: bool = False + paused: bool = False + + +@dataclass(frozen=True) +class ResumePreparation: + events: list[Any] + needs_interrupt: bool + + +@dataclass +class ResumeAcceptance: + accepted: bool = False + + +@dataclass(frozen=True) +class ResumeApplication: + stream: Any + acceptance: ResumeAcceptance + resolved_tools: list[tuple[str, str]] + sideband_recovery_after: int | None + + +class AguiA2AAdapter: + """Map AG-UI lifecycle calls onto one local A2A execution kernel.""" + + def __init__( + self, + *, + a2a_url: str, + client: Any | None = None, + interrupt_ttl: int = 540, + state_store: AguiStateStore | None = None, + state_dir: str | Path | None = None, + ) -> None: + if state_store is not None and state_dir is not None: + raise ValueError("state_store and state_dir are mutually exclusive") + self.a2a_url = a2a_url + self.client = client or A2AClient() + self.interrupt_ttl = max(1, interrupt_ttl) + self._state_store = state_store or FileAguiThreadStateStore(state_dir) + self._lock = asyncio.Lock() + self._threads: dict[str, ThreadBinding] = {} + self._executions: dict[str, ThreadBinding] = {} + self._started = False + self.last_activity = time.monotonic() + + @property + def is_idle(self) -> bool: + return not any(binding.active_run_id or binding.pending for binding in self._threads.values()) + + async def admit( + self, + run_input: RunAgentInput, + request_digest: str, + *, + preferred_language: str | None = None, + ) -> RunTicket: + await self.start() + async with self._lock: + props = parse_forwarded_props(run_input.forwarded_props).iac_code + cwd = resolve_cwd(props.cwd) + validate_tools(run_input) + try: + binding = self._load_thread(run_input.thread_id) + except AguiStateStoreError as exc: + raise AdmissionError( + "STATE_UNAVAILABLE", + "The AG-UI adapter state is unavailable.", + status_code=503, + ) from exc + created = binding is None + previous_execution: tuple[str, str | None, str, int, set[str], set[str], bool, bool] | None = None + if binding is None: + if run_input.resume is not None: + raise AdmissionError("EXECUTION_LOST", "The execution to resume is no longer available.") + binding = ThreadBinding( + thread_id=run_input.thread_id, + context_id=_context_id(run_input.thread_id), + cwd=cwd, + user_id=props.user_id, + ros_invocation_id=props.ros_invocation_id, + ) + self._threads[run_input.thread_id] = binding + self._executions[binding.execution_id] = binding + previous_digest = binding.run_digests.get(run_input.run_id) + if previous_digest is not None: + code = "DUPLICATE_RUN_ID" if previous_digest == request_digest else "RUN_ID_CONFLICT" + raise AdmissionError(code, "The AG-UI run id has already been used.") + if binding.active_run_id is not None: + raise AdmissionError("THREAD_BUSY", "The AG-UI thread already has an active run.") + if binding.cwd != cwd or binding.user_id != props.user_id: + raise AdmissionError( + "THREAD_BINDING_CONFLICT", + "The AG-UI thread is already bound to another workspace or caller.", + ) + if run_input.resume is not None: + if props.ros_invocation_id != binding.ros_invocation_id: + raise AdmissionError("EXECUTION_LOST", "The resume request does not match the interrupted run.") + elif binding.pending: + raise AdmissionError("RESUME_REQUIRED", "The AG-UI thread is waiting for interrupt responses.") + elif not created: + previous_execution = ( + binding.execution_id, + binding.task_id, + binding.ros_invocation_id, + binding.pipeline_sequence, + set(binding.pipeline_open_steps), + set(binding.text_snapshot_digests), + self._executions.get(binding.execution_id) is binding, + binding.execution_id in binding.terminal_execution_ids, + ) + self._rotate_execution(binding, ros_invocation_id=props.ros_invocation_id) + + binding.active_run_id = run_input.run_id + binding.run_digests[run_input.run_id] = request_digest + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + binding.run_digests.pop(run_input.run_id, None) + binding.active_run_id = None + if created: + self._threads.pop(binding.thread_id, None) + self._executions.pop(binding.execution_id, None) + elif previous_execution is not None: + self._executions.pop(binding.execution_id, None) + ( + old_execution_id, + old_task_id, + old_invocation_id, + old_pipeline_sequence, + old_open_steps, + old_text_snapshot_digests, + was_active, + was_terminal, + ) = previous_execution + binding.execution_id = old_execution_id + binding.task_id = old_task_id + binding.ros_invocation_id = old_invocation_id + binding.pipeline_sequence = old_pipeline_sequence + binding.pipeline_open_steps = old_open_steps + binding.text_snapshot_digests = old_text_snapshot_digests + if was_active: + self._executions[old_execution_id] = binding + if was_terminal: + binding.terminal_execution_ids.add(old_execution_id) + else: + binding.terminal_execution_ids.discard(old_execution_id) + raise AdmissionError( + "STATE_UNAVAILABLE", + "The AG-UI adapter state is unavailable.", + status_code=503, + ) from exc + self.last_activity = asyncio.get_running_loop().time() + return RunTicket( + run_input=run_input, + binding=binding, + request_digest=request_digest, + is_resume=run_input.resume is not None, + preferred_language=normalize_agui_language( + props.preferred_language, + fallback=normalize_agui_language(preferred_language), + ), + ) + + async def start(self) -> None: + if self._started: + return + async with self._lock: + if self._started: + return + self._started = True + + async def stream(self, ticket: RunTicket) -> AsyncIterator[Any]: + run_input = ticket.run_input + mapper = A2AEventMapper( + thread_id=run_input.thread_id, + run_id=run_input.run_id, + open_pipeline_steps=ticket.binding.pipeline_open_steps, + text_snapshot_digests=ticket.binding.text_snapshot_digests, + ) + yield RunStartedEvent( + thread_id=run_input.thread_id, + run_id=run_input.run_id, + parent_run_id=run_input.parent_run_id, + input=None, + timestamp=timestamp_ms(), + ) + for reopened in mapper.reopen_pipeline_steps(): + yield reopened + ticket.local_task = asyncio.current_task() + try: + props = parse_forwarded_props(run_input.forwarded_props) + session_emitted = False + terminal_state = "" + resolved_tools: list[tuple[str, str]] = [] + sideband_recovery_after: int | None = None + resume_application: ResumeApplication | None = None + if ticket.is_resume: + preparation = await self._prepare_resume(ticket, mapper) + for event in preparation.events: + yield event + if preparation.needs_interrupt: + finished = await self._commit_interrupt(ticket, mapper) + for closing in mapper.close_all(): + yield closing + yield finished + return + resume_application = await self._apply_resume(ticket, props.iac_code) + stream = resume_application.stream + resolved_tools = resume_application.resolved_tools + sideband_recovery_after = resume_application.sideband_recovery_after + if resume_application.acceptance.accepted: + yield mapper.session_event( + execution_id=ticket.binding.execution_id, + context_id=ticket.binding.context_id, + task_id=ticket.binding.task_id, + ros_invocation_id=ticket.binding.ros_invocation_id, + session_id=ticket.binding.iac_code_session_id, + ) + session_emitted = True + else: + stream = self._new_a2a_stream(ticket, props.iac_code) + try: + async for event in stream: + binding = ticket.binding + task_id = a2a_task_id(event) + if task_id and task_id != binding.task_id: + await self._remember_task(ticket, task_id) + context_id = a2a_context_id(event) + if context_id and context_id != binding.context_id: + raise AguiError("A2A_PROTOCOL_ERROR", "The A2A context identity changed unexpectedly.") + session_id = a2a_iac_code_session_id(event) + if session_id: + await self._remember_session_id(ticket, session_id) + resume_accepted = resume_application is not None and resume_application.acceptance.accepted + if not session_emitted and ( + resume_accepted + or resume_application is None + and binding.task_id + and binding.iac_code_session_id + ): + yield mapper.session_event( + execution_id=binding.execution_id, + context_id=binding.context_id, + task_id=binding.task_id, + ros_invocation_id=binding.ros_invocation_id, + session_id=binding.iac_code_session_id, + ) + session_emitted = True + if resolved_tools: + for tool_call_id, content in resolved_tools: + for resolved in mapper.map_resolved_tool( + tool_call_id=tool_call_id, + content=content, + ): + yield resolved + resolved_tools.clear() + mapped_events = mapper.map(event) + binding.pipeline_sequence = max(binding.pipeline_sequence, mapper.last_pipeline_sequence) + binding.pipeline_open_steps = set(mapper.open_pipeline_steps) + binding.text_snapshot_digests = set(mapper.text_snapshot_digests) + for mapped in mapped_events: + yield mapped + pending_values = [ + value + for value in a2a_inputs(event) + if (binding.execution_id, str(value.get("inputId") or "")) + not in binding.applied_resume_digests + ] + if pending_values: + if sideband_recovery_after is not None: + for recovered in await self._recover_pipeline( + binding, + mapper, + after_sequence=sideband_recovery_after, + ): + yield recovered + binding.pipeline_sequence = max( + binding.pipeline_sequence, + mapper.last_pipeline_sequence, + ) + binding.pipeline_open_steps = set(mapper.open_pipeline_steps) + sideband_recovery_after = None + self._merge_pending( + binding, + pending_values, + replace=False, + sideband_ids=a2a_sideband_input_ids(event), + ) + finished = await self._commit_interrupt(ticket, mapper) + for closing in mapper.close_all(): + yield closing + yield finished + return + state = a2a_state(event) + if state: + terminal_state = state + if state in _FAILED_STATES or state in {"canceled", "completed"}: + break + if state == "input-required" and not pending_values: + break + finally: + close_stream = getattr(stream, "aclose", None) + if close_stream is not None: + await close_stream() + + if sideband_recovery_after is not None: + for recovered in await self._recover_pipeline( + ticket.binding, + mapper, + after_sequence=sideband_recovery_after, + ): + yield recovered + ticket.binding.pipeline_sequence = max( + ticket.binding.pipeline_sequence, + mapper.last_pipeline_sequence, + ) + ticket.binding.pipeline_open_steps = set(mapper.open_pipeline_steps) + + if not session_emitted: + yield mapper.session_event( + execution_id=ticket.binding.execution_id, + context_id=ticket.binding.context_id, + task_id=ticket.binding.task_id, + ros_invocation_id=ticket.binding.ros_invocation_id, + session_id=ticket.binding.iac_code_session_id, + ) + for closing in mapper.close_all(): + yield closing + if terminal_state in _SUCCESS_STATES: + ticket.completed = True + yield RunFinishedEvent( + thread_id=run_input.thread_id, + run_id=run_input.run_id, + outcome=RunFinishedSuccessOutcome(), + usage=aggregate_usage(mapper.usage), + timestamp=timestamp_ms(), + ) + elif terminal_state == "canceled": + ticket.completed = True + yield _run_error( + run_input, + "CANCELLED", + "The execution was cancelled.", + language=ticket.preferred_language, + usage=aggregate_usage(mapper.usage), + ) + else: + ticket.completed = True + yield _run_error( + run_input, + "A2A_EXECUTION_FAILED", + "The A2A execution failed.", + language=ticket.preferred_language, + usage=aggregate_usage(mapper.usage), + ) + except AguiError as exc: + ticket.completed = True + yield _run_error( + run_input, + exc.code, + exc.message, + language=ticket.preferred_language, + usage=aggregate_usage(mapper.usage), + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("AG-UI A2A adapter run failed", extra={"run_id": run_input.run_id}) + await self._cancel_unrecoverable(ticket) + ticket.completed = True + yield _run_error( + run_input, + "A2A_UNAVAILABLE", + "The local A2A execution service is unavailable.", + language=ticket.preferred_language, + usage=aggregate_usage(mapper.usage), + ) + finally: + if ticket.completed and not ticket.binding.pending: + self._mark_execution_terminal(ticket.binding) + self._persist_thread_best_effort(ticket.binding) + await self._release_run(ticket) + + def _new_a2a_stream(self, ticket: RunTicket, props: IacCodeForwardedProps) -> Any: + user_message = latest_user_message(ticket.run_input) + if user_message is None: + raise AguiError("INVALID_INPUT", "A new run requires a user message.") + message_id, parts = user_message + return self.client.stream_message_parts( + self.a2a_url, + parts, + cwd=ticket.binding.cwd, + context_id=ticket.binding.context_id, + message_id=message_id, + **_a2a_request_options(props, preferred_language=ticket.preferred_language), + ) + + async def _apply_resume( + self, + ticket: RunTicket, + props: IacCodeForwardedProps, + ) -> ResumeApplication: + binding = ticket.binding + if binding.task_id is None or not binding.pending: + raise AguiError("EXECUTION_LOST", "The A2A task to resume is unavailable.") + resolutions = self._validate_resume(ticket) + if binding.expiry_task is not None: + binding.expiry_task.cancel() + binding.expiry_task = None + + prompt: str | None = None + resolved_tools: list[tuple[str, str]] = [] + permission_responses: list[tuple[PendingInput, dict[str, Any], str]] = [] + prompt_responses: list[tuple[PendingInput, str]] = [] + try: + for pending, entry, normalized_payload in resolutions: + digest = canonical_digest({"status": str(entry.status), "payload": entry.payload}) + if pending.value.get("kind") == "permission": + decision = "deny" if str(entry.status) == "cancelled" else str(normalized_payload["decision"]) + payload = { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": binding.task_id, + "inputId": pending.interrupt.id, + "toolUseId": str(pending.value.get("toolUseId")), + "decision": decision, + } + permission_responses.append((pending, payload, digest)) + continue + elif str(entry.status) == "cancelled": + await self.client.cancel_task(self.a2a_url, binding.task_id) + binding.pending.clear() + self._mark_execution_terminal(binding) + self._persist_thread(binding) + raise AguiError("CANCELLED", "The execution was cancelled by the interrupt response.") + else: + prompt = resume_value(pending.value, normalized_payload) + if not prompt: + raise AguiError( + "RESUME_PAYLOAD_INVALID", + "The interrupt response does not contain an answer.", + ) + tool_use_id = pending.value.get("toolUseId") + if isinstance(tool_use_id, str) and tool_use_id: + resolved_tools.append((tool_use_id, prompt)) + prompt_responses.append((pending, digest)) + except BaseException: + if binding.pending: + self._schedule_expiry(binding) + raise + + acceptance = ResumeAcceptance() + if prompt is not None: + stream = self.client.stream_message( + self.a2a_url, + prompt, + cwd=binding.cwd, + context_id=binding.context_id, + task_id=binding.task_id, + message_id=f"agui-resume-{ticket.run_input.run_id}", + **_a2a_request_options(props, preferred_language=ticket.preferred_language), + ) + return ResumeApplication( + stream=self._stream_prompt_response(binding, stream, prompt_responses, acceptance), + acceptance=acceptance, + resolved_tools=resolved_tools, + sideband_recovery_after=None, + ) + if permission_responses: + recovery_after = binding.pipeline_sequence if binding.pipeline_sequence > 0 else None + if all(pending.sideband for pending, _payload, _digest in permission_responses): + await self._send_pipeline_permission_responses( + binding, + props, + permission_responses, + preferred_language=ticket.preferred_language, + ) + acceptance.accepted = True + stream = self._stream_after_sideband(binding) + else: + stream = self._stream_permission_responses(ticket, props, permission_responses, acceptance) + return ResumeApplication( + stream=stream, + acceptance=acceptance, + resolved_tools=[], + sideband_recovery_after=recovery_after, + ) + return ResumeApplication( + stream=self.client.subscribe_task(self.a2a_url, binding.task_id), + acceptance=acceptance, + resolved_tools=[], + sideband_recovery_after=None, + ) + + async def _stream_prompt_response( + self, + binding: ThreadBinding, + stream: Any, + responses: list[tuple[PendingInput, str]], + acceptance: ResumeAcceptance, + ) -> AsyncIterator[dict[str, Any]]: + accepted = False + try: + async for event in stream: + self._validate_resume_acceptance_event(binding, event) + if not accepted: + await self._commit_accepted_inputs(binding, responses) + acceptance.accepted = True + accepted = True + yield event + except BaseException: + if binding.pending: + self._schedule_expiry(binding) + raise + finally: + close_stream = getattr(stream, "aclose", None) + if close_stream is not None: + await close_stream() + if not accepted: + if binding.pending: + self._schedule_expiry(binding) + raise AguiError("A2A_UNAVAILABLE", "The A2A interrupt response was not accepted.") + + async def _commit_accepted_inputs( + self, + binding: ThreadBinding, + responses: list[tuple[PendingInput, str]], + ) -> None: + for pending, digest in responses: + binding.applied_resume_digests[(binding.execution_id, pending.interrupt.id)] = digest + binding.pending.pop(pending.interrupt.id, None) + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + await self._cancel_for_state_failure(binding) + raise AguiError( + "STATE_PERSISTENCE_FAILED", + "The accepted interrupt response could not be committed.", + ) from exc + + def _validate_resume_acceptance_event(self, binding: ThreadBinding, event: Any) -> None: + _raise_for_a2a_error(event) + task_id = a2a_task_id(event) + if task_id and task_id != binding.task_id: + raise AguiError("A2A_PROTOCOL_ERROR", "The A2A task identity changed unexpectedly.") + context_id = a2a_context_id(event) + if context_id and context_id != binding.context_id: + raise AguiError("A2A_PROTOCOL_ERROR", "The A2A context identity changed unexpectedly.") + + async def _recover_pipeline( + self, + binding: ThreadBinding, + mapper: A2AEventMapper, + *, + after_sequence: int, + ) -> list[Any]: + if binding.task_id is None: + return [] + get_pipeline_state = getattr(self.client, "get_pipeline_state", None) + if get_pipeline_state is None: + return [] + pipeline_state = await get_pipeline_state( + self.a2a_url, + task_id=binding.task_id, + after_sequence=after_sequence, + ) + if not isinstance(pipeline_state, Mapping): + return [] + return mapper.map_pipeline_recovery(pipeline_state) + + async def _send_pipeline_permission_responses( + self, + binding: ThreadBinding, + props: IacCodeForwardedProps, + responses: list[tuple[PendingInput, dict[str, Any], str]], + *, + preferred_language: str, + ) -> None: + assert binding.task_id is not None + try: + for pending, payload, digest in responses: + response = await self.client.send_message_parts( + self.a2a_url, + [{"data": payload, "mediaType": "application/json"}], + cwd=binding.cwd, + context_id=binding.context_id, + task_id=binding.task_id, + message_id=f"agui-resume-{pending.interrupt.id}", + **_a2a_request_options(props, preferred_language=preferred_language), + ) + _raise_for_a2a_error(response) + await self._commit_accepted_inputs(binding, [(pending, digest)]) + except BaseException: + if binding.pending: + self._schedule_expiry(binding) + raise + + async def _stream_permission_responses( + self, + ticket: RunTicket, + props: IacCodeForwardedProps, + responses: list[tuple[PendingInput, dict[str, Any], str]], + acceptance: ResumeAcceptance, + ) -> AsyncIterator[dict[str, Any]]: + binding = ticket.binding + assert binding.task_id is not None + terminal_seen = False + try: + for index, (pending, payload, digest) in enumerate(responses): + stream = self.client.stream_message_parts( + self.a2a_url, + [{"data": payload, "mediaType": "application/json"}], + cwd=binding.cwd, + context_id=binding.context_id, + task_id=binding.task_id, + message_id=f"agui-resume-{ticket.run_input.run_id}-{pending.interrupt.id}", + **_a2a_request_options(props, preferred_language=ticket.preferred_language), + ) + accepted = False + try: + async for event in stream: + self._validate_resume_acceptance_event(binding, event) + if not accepted: + await self._commit_accepted_inputs(binding, [(pending, digest)]) + if index == len(responses) - 1: + acceptance.accepted = True + accepted = True + if a2a_state(event) in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + terminal_seen = True + yield event + finally: + close_stream = getattr(stream, "aclose", None) + if close_stream is not None: + await close_stream() + if not accepted: + raise AguiError("A2A_UNAVAILABLE", "The A2A permission response was not accepted.") + if not terminal_seen: + stream = self.client.subscribe_task(self.a2a_url, binding.task_id) + try: + async for event in stream: + yield event + finally: + close_stream = getattr(stream, "aclose", None) + if close_stream is not None: + await close_stream() + except BaseException: + if binding.pending: + self._schedule_expiry(binding) + raise + + async def _stream_after_sideband(self, binding: ThreadBinding) -> AsyncIterator[dict[str, Any]]: + """Close the send/subscribe race with one authoritative task snapshot.""" + + assert binding.task_id is not None + task = await self.client.get_task(self.a2a_url, binding.task_id, history_length=100) + yield task + if a2a_state(task) in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + return + stream = self.client.subscribe_task(self.a2a_url, binding.task_id) + try: + async for event in stream: + if isinstance(event, Mapping) and isinstance(event.get("error"), Mapping): + refreshed = await self.client.get_task(self.a2a_url, binding.task_id, history_length=100) + if a2a_state(refreshed) not in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + raise RuntimeError("A2A task subscription returned an error before terminal state") + yield refreshed + return + yield event + if a2a_state(event) in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + return + refreshed = await self.client.get_task(self.a2a_url, binding.task_id, history_length=100) + if a2a_state(refreshed) not in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + raise RuntimeError("A2A task subscription ended before terminal state") + yield refreshed + except Exception: + # The task can become terminal between GetTask and SubscribeToTask. + # Only suppress the subscribe failure when a second authoritative + # snapshot proves that this exact task completed normally. + refreshed = await self.client.get_task(self.a2a_url, binding.task_id, history_length=100) + if a2a_state(refreshed) not in _FAILED_STATES | _SUCCESS_STATES | {"canceled"}: + raise + yield refreshed + finally: + close_stream = getattr(stream, "aclose", None) + if close_stream is not None: + await close_stream() + + async def _prepare_resume( + self, + ticket: RunTicket, + mapper: A2AEventMapper, + ) -> ResumePreparation: + binding = ticket.binding + if binding.task_id is None or not binding.pending: + entries = ticket.run_input.resume or [] + if entries and all( + binding.applied_resume_digests.get((binding.execution_id, entry.interrupt_id)) + == canonical_digest({"status": str(entry.status), "payload": entry.payload}) + for entry in entries + ): + raise AguiError("RESUME_ALREADY_APPLIED", "The interrupt response has already been applied.") + raise AguiError("EXECUTION_LOST", "The A2A task to resume is unavailable.") + + ensure_session_restored = getattr(self.client, "ensure_session_restored", None) + if binding.iac_code_session_id is not None and callable(ensure_session_restored): + session_ready = await ensure_session_restored( + self.a2a_url, + cwd=binding.cwd, + session_id=binding.iac_code_session_id, + ) + if not session_ready: + raise AguiError("EXECUTION_LOST", "The iac-code session to resume is unavailable.") + + task = await self.client.get_task(self.a2a_url, binding.task_id, history_length=100) + task_id = a2a_task_id(task) + context_id = a2a_context_id(task) + if task_id != binding.task_id or (context_id and context_id != binding.context_id): + raise AguiError("A2A_PROTOCOL_ERROR", "The A2A task identity does not match the interrupted run.") + session_id = a2a_iac_code_session_id(task) + if session_id: + await self._remember_session_id(ticket, session_id) + + # A task snapshot can contain only a bounded suffix of Pipeline events. + # Mapping that suffix before the authoritative sequence-based recovery can + # both duplicate old events and advance past an omitted tool start. + # GetTask returns the last status message as a snapshot. That text was + # already delivered before the interrupt; replaying it on every AG-UI + # resume appends duplicate content to the same message id. + events = mapper.map(task, include_pipeline=False, include_status_text=False) + recovery_after = max(binding.pipeline_sequence, mapper.last_pipeline_sequence) + get_pipeline_state = getattr(self.client, "get_pipeline_state", None) + if get_pipeline_state is not None: + pipeline_state = await get_pipeline_state( + self.a2a_url, + task_id=binding.task_id, + after_sequence=recovery_after, + ) + if isinstance(pipeline_state, Mapping): + events.extend(mapper.map_pipeline_recovery(pipeline_state)) + binding.pipeline_sequence = max(binding.pipeline_sequence, mapper.last_pipeline_sequence) + binding.pipeline_open_steps = set(mapper.open_pipeline_steps) + + known_pending_ids = set(binding.pending) + current_inputs = a2a_inputs(task) + task_metadata = a2a_iac_code_metadata(task) + # A task restored after an A2A process restart is only a durable + # summary and does not contain the original input metadata. Absence of + # that projection is not evidence that the adapter's durable interrupt + # disappeared: the following permission response is what lets A2A load + # its permission checkpoint. Explicit projections (including an empty + # one) remain authoritative, as do terminal task states. + replace_pending = ( + "input" in task_metadata + or "pendingPermissions" in task_metadata + or a2a_state(task) in _FAILED_STATES | {"canceled", "completed"} + ) + self._merge_pending( + binding, + current_inputs, + replace=replace_pending, + sideband_ids=a2a_sideband_input_ids(task), + ) + entries = ticket.run_input.resume or [] + entry_ids = [entry.interrupt_id for entry in entries] + if len(entry_ids) != len(set(entry_ids)): + raise AguiError("INCOMPLETE_RESUME", "The resume contains duplicate interrupt ids.") + entries_by_id = {entry.interrupt_id: entry for entry in entries} + for interrupt_id in tuple(binding.pending): + applied = binding.applied_resume_digests.get((binding.execution_id, interrupt_id)) + if applied is None: + continue + entry = entries_by_id.get(interrupt_id) + if entry is not None: + digest = canonical_digest({"status": str(entry.status), "payload": entry.payload}) + if digest != applied: + raise AguiError("RESUME_ALREADY_APPLIED", "The interrupt response has already been applied.") + # A successful A2A SendMessage is authoritative for adapter + # idempotency even if GetTask briefly returns a stale permission. + binding.pending.pop(interrupt_id, None) + if binding.pending and self._pending_expired(binding): + await self._expire(binding, binding.execution_id) + raise AguiError("EXECUTION_EXPIRED", "The interrupted execution has expired.") + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + await self._cancel_for_state_failure(binding) + raise AguiError( + "STATE_PERSISTENCE_FAILED", + "The interrupted execution state could not be committed.", + ) from exc + + supplied_ids = set(entry_ids) + pending_ids = set(binding.pending) + for entry in entries: + if entry.interrupt_id in pending_ids: + continue + digest = canonical_digest({"status": str(entry.status), "payload": entry.payload}) + applied = binding.applied_resume_digests.get((binding.execution_id, entry.interrupt_id)) + if applied is not None and applied == digest: + continue + if applied is not None: + raise AguiError("RESUME_ALREADY_APPLIED", "The interrupt response has already been applied.") + raise AguiError("UNKNOWN_INTERRUPT", "The resume references an unknown interrupt.") + missing_ids = pending_ids - supplied_ids + if missing_ids & known_pending_ids: + raise AguiError("INCOMPLETE_RESUME", "The resume must resolve every pending interrupt exactly once.") + needs_interrupt = bool(missing_ids) + if not needs_interrupt: + # Validate every response before stream() emits the private session + # event that tells ROS the parked execution accepted this resume. + # A schema error must leave both the AG-UI pending inputs and the + # ROS input-required barrier available for a corrected retry. + self._validate_resume(ticket) + return ResumePreparation(events=events, needs_interrupt=needs_interrupt) + + async def _remember_task(self, ticket: RunTicket, task_id: str) -> None: + binding = ticket.binding + if binding.task_id is not None and binding.task_id != task_id: + raise AguiError("A2A_PROTOCOL_ERROR", "The A2A task identity changed unexpectedly.") + binding.task_id = task_id + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + await self._cancel_for_state_failure(binding) + raise AguiError( + "STATE_PERSISTENCE_FAILED", + "The execution mapping could not be committed.", + ) from exc + + async def _remember_session_id(self, ticket: RunTicket, session_id: str) -> None: + binding = ticket.binding + if binding.iac_code_session_id is not None: + if binding.iac_code_session_id != session_id: + raise AguiError("A2A_PROTOCOL_ERROR", "The iac-code session identity changed unexpectedly.") + return + binding.iac_code_session_id = session_id + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + await self._cancel_for_state_failure(binding) + raise AguiError( + "STATE_PERSISTENCE_FAILED", + "The execution session mapping could not be committed.", + ) from exc + + async def _commit_interrupt(self, ticket: RunTicket, mapper: A2AEventMapper) -> RunFinishedEvent: + binding = ticket.binding + mapper.finalize_text_snapshots() + binding.pipeline_sequence = max(binding.pipeline_sequence, mapper.last_pipeline_sequence) + binding.text_snapshot_digests = set(mapper.text_snapshot_digests) + try: + self._persist_thread(binding) + except AguiStateStoreError as exc: + await self._cancel_for_state_failure(binding) + raise AguiError( + "STATE_PERSISTENCE_FAILED", + "The interrupted execution state could not be committed.", + ) from exc + self._schedule_expiry(binding) + # This durable/paused marker must precede yielding any terminal event. The + # ASGI response may be closed immediately after the client reads it. + ticket.paused = True + ticket.completed = True + return RunFinishedEvent( + thread_id=ticket.run_input.thread_id, + run_id=ticket.run_input.run_id, + outcome=RunFinishedInterruptOutcome(interrupts=[item.interrupt for item in binding.pending.values()]), + usage=aggregate_usage(mapper.usage), + timestamp=timestamp_ms(), + ) + + def _merge_pending( + self, + binding: ThreadBinding, + values: list[dict[str, Any]], + *, + replace: bool, + sideband_ids: set[str] | None = None, + ) -> None: + sideband_ids = sideband_ids or set() + previous = binding.pending + merged: dict[str, PendingInput] = {} if replace else dict(previous) + for value in values: + request_task_id = value.get("requestTaskId") + context_id = value.get("contextId") + if request_task_id and request_task_id != binding.task_id: + raise AguiError("A2A_PROTOCOL_ERROR", "An interrupt belongs to another A2A task.") + if context_id and context_id != binding.context_id: + raise AguiError("A2A_PROTOCOL_ERROR", "An interrupt belongs to another A2A context.") + input_id = str(value.get("inputId") or "") + existing = previous.get(input_id) + if existing is not None: + if input_id in sideband_ids: + existing.sideband = True + merged[input_id] = existing + continue + pending = PendingInput( + value=_persistent_input(value), + interrupt=interrupt_from_a2a(_persistent_input(value), ttl_seconds=self.interrupt_ttl), + sideband=input_id in sideband_ids, + ) + merged[pending.interrupt.id] = pending + binding.pending = merged + + def _validate_resume(self, ticket: RunTicket) -> list[tuple[PendingInput, Any, Any]]: + entries = ticket.run_input.resume or [] + entry_ids = [entry.interrupt_id for entry in entries] + if len(entry_ids) != len(set(entry_ids)): + raise AguiError("INCOMPLETE_RESUME", "The resume contains duplicate interrupt ids.") + binding = ticket.binding + supplied_ids = set(entry_ids) + pending_ids = set(binding.pending) + if pending_ids - supplied_ids: + raise AguiError("INCOMPLETE_RESUME", "The resume must resolve every pending interrupt exactly once.") + output: list[tuple[PendingInput, Any, Any]] = [] + for entry in entries: + if entry.interrupt_id not in binding.pending: + continue + pending = binding.pending[entry.interrupt_id] + payload = entry.payload + if str(entry.status) == "cancelled" and pending.value.get("kind") == "permission": + payload = {"decision": "deny"} + elif str(entry.status) != "cancelled" and payload is None: + raise AguiError("RESUME_PAYLOAD_INVALID", "A resolved interrupt requires a payload.") + if str(entry.status) != "cancelled" or pending.value.get("kind") == "permission": + try: + validate_json_schema(instance=payload, schema=pending.interrupt.response_schema) + except JsonSchemaValidationError as exc: + raise AguiError("RESUME_PAYLOAD_INVALID", "The interrupt response payload is invalid.") from exc + output.append((pending, entry, payload)) + if not output: + raise AguiError("RESUME_ALREADY_APPLIED", "The interrupt response has already been applied.") + return output + + async def disconnect(self, ticket: RunTicket) -> None: + if ticket.completed or ticket.paused or ticket.binding.pending: + return + await self._cancel_unrecoverable(ticket) + ticket.completed = True + await self._release_run(ticket) + + async def cancel(self, execution_id: str, *, thread_id: str, ros_invocation_id: str) -> str: + await self.start() + async with self._lock: + binding = self._load_thread(thread_id) + if binding is None: + return "not_found" + binding = self._executions.get(execution_id) + if binding is None: + thread = self._threads.get(thread_id) + return "already_terminal" if thread and execution_id in thread.terminal_execution_ids else "not_found" + if binding.thread_id != thread_id or binding.ros_invocation_id != ros_invocation_id: + return "not_found" + if binding.task_id: + await self.client.cancel_task(self.a2a_url, binding.task_id) + binding.pending.clear() + if binding.expiry_task is not None: + binding.expiry_task.cancel() + binding.expiry_task = None + self._mark_execution_terminal(binding) + self._persist_thread(binding) + return "cancelled" + + async def aclose(self) -> None: + for binding in self._threads.values(): + if binding.expiry_task is not None: + binding.expiry_task.cancel() + binding.expiry_task = None + close = getattr(self.client, "aclose", None) + if close is not None: + await close() + + async def _release_run(self, ticket: RunTicket) -> None: + async with self._lock: + if ticket.binding.active_run_id == ticket.run_input.run_id: + ticket.binding.active_run_id = None + ticket.local_task = None + self.last_activity = asyncio.get_running_loop().time() + + def _rotate_execution(self, binding: ThreadBinding, *, ros_invocation_id: str) -> None: + if binding.task_id is not None or binding.execution_id in self._executions: + self._executions.pop(binding.execution_id, None) + binding.terminal_execution_ids.add(binding.execution_id) + binding.execution_id = str(uuid.uuid4()) + binding.task_id = None + binding.ros_invocation_id = ros_invocation_id + binding.pipeline_sequence = 0 + binding.pipeline_open_steps.clear() + binding.text_snapshot_digests.clear() + self._executions[binding.execution_id] = binding + + def _mark_execution_terminal(self, binding: ThreadBinding) -> None: + if self._executions.get(binding.execution_id) is binding: + self._executions.pop(binding.execution_id, None) + binding.terminal_execution_ids.add(binding.execution_id) + binding.task_id = None + + def _schedule_expiry(self, binding: ThreadBinding) -> None: + if not binding.pending: + return + if binding.expiry_task is not None: + binding.expiry_task.cancel() + binding.expiry_task = asyncio.create_task( + self._expire(binding, binding.execution_id), + name=f"agui-a2a-expiry-{binding.execution_id}", + ) + + async def _expire(self, binding: ThreadBinding, execution_id: str) -> None: + try: + expires_at = _pending_expires_at(binding) + if expires_at is None: + return + delay = max(0.0, (expires_at - datetime.now(timezone.utc)).total_seconds()) + await asyncio.sleep(delay) + if binding.execution_id != execution_id or not binding.pending: + return + if binding.task_id: + with contextlib.suppress(Exception): + await self.client.cancel_task(self.a2a_url, binding.task_id) + binding.pending.clear() + self._mark_execution_terminal(binding) + self._persist_thread_best_effort(binding) + except asyncio.CancelledError: + return + finally: + if binding.expiry_task is asyncio.current_task(): + binding.expiry_task = None + + async def _cancel_unrecoverable(self, ticket: RunTicket) -> None: + binding = ticket.binding + if binding.pending: + return + if binding.task_id: + with contextlib.suppress(Exception): + await self.client.cancel_task(self.a2a_url, binding.task_id) + self._mark_execution_terminal(binding) + self._persist_thread_best_effort(binding) + + async def _cancel_for_state_failure(self, binding: ThreadBinding) -> None: + if binding.task_id: + with contextlib.suppress(Exception): + await self.client.cancel_task(self.a2a_url, binding.task_id) + binding.pending.clear() + if binding.expiry_task is not None: + binding.expiry_task.cancel() + binding.expiry_task = None + self._mark_execution_terminal(binding) + self._persist_thread_best_effort(binding) + + def _pending_expired(self, binding: ThreadBinding) -> bool: + expires_at = _pending_expires_at(binding) + return expires_at is not None and expires_at <= datetime.now(timezone.utc) + + def _persist_thread(self, binding: ThreadBinding) -> None: + self._state_store.save_thread(binding.thread_id, self._thread_state_document(binding)) + + def _persist_thread_best_effort(self, binding: ThreadBinding) -> None: + try: + self._persist_thread(binding) + except AguiStateStoreError: + logger.warning("Failed to persist AG-UI thread state", exc_info=True) + + def _thread_state_document(self, binding: ThreadBinding) -> dict[str, Any]: + pending = { + input_id: { + "value": _persistent_input(item.value), + "expiresAt": item.interrupt.expires_at, + "sideband": item.sideband, + } + for input_id, item in binding.pending.items() + } + return { + "schemaVersion": AGUI_STATE_SCHEMA_VERSION, + "threadId": binding.thread_id, + "contextId": binding.context_id, + "cwd": binding.cwd, + "userId": binding.user_id, + "iacCodeSessionId": binding.iac_code_session_id, + "execution": { + "executionId": binding.execution_id, + "rosInvocationId": binding.ros_invocation_id, + "taskId": binding.task_id, + "pipelineSequence": binding.pipeline_sequence, + "pipelineOpenSteps": sorted(binding.pipeline_open_steps), + "textSnapshotDigests": sorted(binding.text_snapshot_digests), + "pending": pending, + }, + "runDigests": dict(binding.run_digests), + "appliedResumeDigests": [ + {"executionId": execution_id, "interruptId": interrupt_id, "digest": digest} + for (execution_id, interrupt_id), digest in sorted(binding.applied_resume_digests.items()) + ], + "terminalExecutionIds": sorted(binding.terminal_execution_ids), + } + + def _load_thread(self, thread_id: str) -> ThreadBinding | None: + binding = self._threads.get(thread_id) + if binding is not None: + return binding + document = self._state_store.load_thread(thread_id) + if document is None: + return None + binding = self._restore_thread_state(document, expected_thread_id=thread_id) + self._threads[thread_id] = binding + if binding.execution_id not in binding.terminal_execution_ids and binding.task_id: + self._executions[binding.execution_id] = binding + if binding.pending: + self._schedule_expiry(binding) + return binding + + def _restore_thread_state( + self, + document: Mapping[str, Any], + *, + expected_thread_id: str, + ) -> ThreadBinding: + try: + thread_id = _required_state_string(document, "threadId") + if thread_id != expected_thread_id: + raise ValueError("thread state key mismatch") + raw_execution = document.get("execution") + run_digests = document.get("runDigests") + applied_digests = document.get("appliedResumeDigests") + terminal_ids = document.get("terminalExecutionIds") + if not isinstance(raw_execution, Mapping): + raise ValueError("execution state is invalid") + if not isinstance(run_digests, Mapping) or not isinstance(applied_digests, list): + raise ValueError("digest state is invalid") + if not isinstance(terminal_ids, list): + raise ValueError("terminal execution state is invalid") + restored_terminal = {value for value in terminal_ids if isinstance(value, str) and value} + user_id = document.get("userId") + task_id = raw_execution.get("taskId") + iac_code_session_id = document.get("iacCodeSessionId") + if user_id is not None and not isinstance(user_id, str): + raise ValueError("userId is invalid") + if task_id is not None and not isinstance(task_id, str): + raise ValueError("taskId is invalid") + if iac_code_session_id is not None and not isinstance(iac_code_session_id, str): + raise ValueError("iacCodeSessionId is invalid") + sequence = raw_execution.get("pipelineSequence", 0) + if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0: + raise ValueError("pipelineSequence is invalid") + raw_open_steps = raw_execution.get("pipelineOpenSteps", []) + if not isinstance(raw_open_steps, list) or not all( + isinstance(value, str) and value for value in raw_open_steps + ): + raise ValueError("pipelineOpenSteps is invalid") + raw_text_digests = raw_execution.get("textSnapshotDigests", []) + if not isinstance(raw_text_digests, list) or not all( + isinstance(value, str) + and len(value) == 64 + and all(character in "0123456789abcdef" for character in value) + for value in raw_text_digests + ): + raise ValueError("textSnapshotDigests is invalid") + pending_value = raw_execution.get("pending", {}) + if not isinstance(pending_value, Mapping): + raise ValueError("pending state is invalid") + pending: dict[str, PendingInput] = {} + for input_id, raw_pending in pending_value.items(): + if not isinstance(input_id, str) or not isinstance(raw_pending, Mapping): + raise ValueError("pending entry is invalid") + value = raw_pending.get("value") + expires_at = raw_pending.get("expiresAt") + sideband = raw_pending.get("sideband", False) + if ( + not isinstance(value, Mapping) + or not isinstance(expires_at, str) + or not isinstance(sideband, bool) + ): + raise ValueError("pending entry is invalid") + safe_value = _persistent_input(value) + if safe_value.get("inputId") != input_id: + raise ValueError("pending input identity mismatch") + _parse_expires_at(expires_at) + interrupt = interrupt_from_a2a(safe_value, ttl_seconds=1).model_copy( + update={"expires_at": expires_at} + ) + pending[input_id] = PendingInput(value=safe_value, interrupt=interrupt, sideband=sideband) + restored_run_digests = { + key: value + for key, value in run_digests.items() + if isinstance(key, str) and key and isinstance(value, str) and value + } + restored_applied: dict[tuple[str, str], str] = {} + for item in applied_digests: + if not isinstance(item, Mapping): + raise ValueError("applied resume digest is invalid") + execution_id = _required_state_string(item, "executionId") + interrupt_id = _required_state_string(item, "interruptId") + digest = _required_state_string(item, "digest") + restored_applied[(execution_id, interrupt_id)] = digest + return ThreadBinding( + thread_id=thread_id, + context_id=_required_state_string(document, "contextId"), + cwd=_required_state_string(document, "cwd"), + user_id=user_id, + ros_invocation_id=_required_state_string(raw_execution, "rosInvocationId"), + iac_code_session_id=iac_code_session_id, + execution_id=_required_state_string(raw_execution, "executionId"), + task_id=task_id, + pending=pending, + pipeline_sequence=sequence, + pipeline_open_steps={value for value in raw_open_steps if isinstance(value, str)}, + text_snapshot_digests={value for value in raw_text_digests if isinstance(value, str)}, + run_digests=restored_run_digests, + applied_resume_digests=restored_applied, + terminal_execution_ids=restored_terminal, + ) + except Exception as exc: + raise AguiStateStoreError("Invalid AG-UI thread state contents.") from exc + + +_PERSISTED_INPUT_FIELDS = frozenset( + { + "schemaVersion", + "kind", + "requestTaskId", + "contextId", + "inputId", + "toolUseId", + "toolName", + "title", + "purpose", + "effect", + "target", + "isReadOnly", + "prompt", + "safeSummary", + "options", + "language", + "deploymentSummary", + "allowFreeText", + "freeTextPrompt", + "required", + } +) + + +def _persistent_input(value: Mapping[str, Any]) -> dict[str, Any]: + """Keep only the public A2A input projection, never request/tool secrets.""" + + return {key: copy.deepcopy(item) for key, item in value.items() if key in _PERSISTED_INPUT_FIELDS} + + +def _required_state_string(value: Mapping[str, Any], key: str) -> str: + result = value.get(key) + if not isinstance(result, str) or not result: + raise ValueError(f"{key} is required") + return result + + +def _parse_expires_at(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _pending_expires_at(binding: ThreadBinding) -> datetime | None: + values = [ + _parse_expires_at(item.interrupt.expires_at) + for item in binding.pending.values() + if isinstance(item.interrupt.expires_at, str) and item.interrupt.expires_at + ] + return min(values) if values else None + + +def _raise_for_a2a_error(response: Any) -> None: + payload = getattr(response, "payload", response) + if isinstance(payload, Mapping) and isinstance(payload.get("error"), Mapping): + raise AguiError("A2A_UNAVAILABLE", "The local A2A execution service rejected the interrupt response.") + + +def _a2a_request_options( + props: IacCodeForwardedProps, + *, + preferred_language: str | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "user_id": props.user_id, + "channel": props.channel, + "preferredLanguage": preferred_language or props.preferred_language, + "candidatePresentation": props.candidate_presentation, + "run_mode": props.run_mode, + "pipeline_name": props.pipeline_name, + "cleanupOnly": props.cleanup_only, + "rosInvocationId": props.ros_invocation_id, + } + cloud = props.alibaba_cloud + if cloud is not None: + metadata.update( + { + "alibaba_cloud_access_key_id": cloud.access_key_id, + "alibaba_cloud_access_key_secret": cloud.access_key_secret, + "alibaba_cloud_security_token": cloud.security_token, + "alibaba_cloud_region_id": cloud.region_id, + } + ) + metadata = {key: value for key, value in metadata.items() if value is not None} + thinking = props.thinking + return { + "model": props.model, + "iac_code_api_key": props.llm_api_key, + "thinking_enabled": thinking.enabled if thinking is not None else None, + "thinking_effort": thinking.effort if thinking is not None else None, + "thinking_budget": thinking.budget if thinking is not None else None, + "iac_code_metadata": metadata, + } + + +def _context_id(thread_id: str) -> str: + digest = hashlib.sha256(thread_id.encode("utf-8")).hexdigest() + return f"agui-{digest[:40]}" + + +def _run_error( + run_input: RunAgentInput, + code: str, + message: str, + *, + language: str, + usage: list[TokenUsage] | None = None, +) -> RunErrorEvent: + return RunErrorEvent.model_validate( + { + "threadId": run_input.thread_id, + "runId": run_input.run_id, + "code": code, + "message": translate_agui_error(message, language=language), + "usage": usage, + "timestamp": timestamp_ms(), + } + ) diff --git a/src/iac_code/agui/app.py b/src/iac_code/agui/app.py new file mode 100644 index 00000000..5620c273 --- /dev/null +++ b/src/iac_code/agui/app.py @@ -0,0 +1,341 @@ +"""Starlette application exposing an A2A-backed AG-UI POST/SSE endpoint.""" + +from __future__ import annotations + +import asyncio +import contextlib +import hmac +import json +import math +import os +from collections.abc import AsyncIterator, Callable, Mapping +from pathlib import Path +from typing import Any + +from ag_ui.encoder import EventEncoder +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route + +from iac_code import __version__ +from iac_code.agui.adapter import AguiA2AAdapter, RunTicket +from iac_code.agui.errors import AdmissionError, normalize_agui_language, translate_agui_error +from iac_code.agui.inputs import MAX_REQUEST_BYTES, canonical_digest, parse_run_input +from iac_code.i18n import resolve_ui_language, translate_message + +_HEARTBEAT_SECONDS = 15.0 + + +def create_app( + *, + adapter: AguiA2AAdapter | None = None, + a2a_url: str = "http://127.0.0.1:41242/", + a2a_client: Any | None = None, + auth_token: str | None = None, + interrupt_ttl: int = 540, + state_dir: str | Path | None = None, + max_request_bytes: int = MAX_REQUEST_BYTES, + idle_shutdown: float = 0, + request_shutdown: Callable[[], None] | None = None, +) -> Starlette: + if idle_shutdown > 0 and request_shutdown is None: + raise ValueError("idle_shutdown requires a server shutdown callback") + run_adapter = adapter or AguiA2AAdapter( + a2a_url=a2a_url, + client=a2a_client, + interrupt_ttl=interrupt_ttl, + state_dir=state_dir, + ) + owns_adapter = adapter is None + expected_token = auth_token if auth_token is not None else os.environ.get("IAC_CODE_AGUI_AUTH_TOKEN") + + async def health(_: Request) -> JSONResponse: + return JSONResponse( + { + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": __version__, + } + ) + + async def run(request: Request) -> Response: + language = _request_language(request) + auth_error = _authorize(request, expected_token, language=language) + if auth_error is not None: + return auth_error + if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": + return _json_error( + 415, + "UNSUPPORTED_MEDIA_TYPE", + translate_message("Content-Type must be application/json.", language=language), + ) + try: + body = await _bounded_body(request, max_request_bytes) + except _BodyTooLargeError: + return _json_error( + 413, + "REQUEST_TOO_LARGE", + translate_message("The AG-UI request body is too large.", language=language), + ) + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + return _json_error( + 400, + "INVALID_JSON", + translate_message("The request body is not valid JSON.", language=language), + ) + if not isinstance(payload, dict): + return _json_error( + 400, + "INVALID_INPUT", + translate_message("RunAgentInput must be a JSON object.", language=language), + ) + language = _payload_language(payload, fallback=language) + try: + run_input = parse_run_input(payload) + except ValueError: + return _json_error( + 400, + "INVALID_INPUT", + translate_message("Invalid AG-UI RunAgentInput envelope.", language=language), + ) + try: + ticket = await run_adapter.admit( + run_input, + canonical_digest(payload), + preferred_language=language, + ) + except AdmissionError as exc: + return _json_error( + exc.status_code, + exc.code, + translate_agui_error(exc.message, language=language), + ) + except Exception: + return _json_error( + 400, + "INVALID_INPUT", + translate_message("Invalid iac-code forwarded properties.", language=language), + ) + return StreamingResponse( + _event_stream(run_adapter, ticket), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + async def cancel(request: Request) -> Response: + language = _request_language(request) + auth_error = _authorize(request, expected_token, language=language) + if auth_error is not None: + return auth_error + if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": + return _json_error( + 415, + "UNSUPPORTED_MEDIA_TYPE", + translate_message("Content-Type must be application/json.", language=language), + ) + try: + body = await _bounded_body(request, min(max_request_bytes, 64 * 1024)) + payload = json.loads(body) + except _BodyTooLargeError: + return _json_error( + 413, + "REQUEST_TOO_LARGE", + translate_message("The cancel request body is too large.", language=language), + ) + except (UnicodeDecodeError, json.JSONDecodeError): + return _json_error( + 400, + "INVALID_JSON", + translate_message("The cancel request body is not valid JSON.", language=language), + ) + if not isinstance(payload, dict): + return _json_error( + 400, + "INVALID_INPUT", + translate_message("The cancel request must be a JSON object.", language=language), + ) + thread_id = payload.get("threadId") + ros_invocation_id = payload.get("rosInvocationId") + if not all(isinstance(value, str) and value for value in (thread_id, ros_invocation_id)): + return _json_error( + 400, + "INVALID_INPUT", + translate_message("threadId and rosInvocationId are required.", language=language), + ) + status = await run_adapter.cancel( + request.path_params["execution_id"], + thread_id=thread_id, + ros_invocation_id=ros_invocation_id, + ) + if status == "not_found": + return _json_error( + 404, + "EXECUTION_NOT_FOUND", + translate_message("The execution was not found.", language=language), + ) + return JSONResponse({"executionId": request.path_params["execution_id"], "status": status}) + + @contextlib.asynccontextmanager + async def lifespan(_: Starlette) -> Any: + monitor: asyncio.Task[None] | None = None + await run_adapter.start() + if idle_shutdown > 0: + assert request_shutdown is not None + monitor = asyncio.create_task( + _monitor_idle(run_adapter, idle_shutdown, request_shutdown), + name="agui-idle-shutdown", + ) + try: + yield + finally: + if monitor is not None: + monitor.cancel() + with contextlib.suppress(asyncio.CancelledError): + await monitor + if owns_adapter: + await run_adapter.aclose() + + app = Starlette( + routes=[ + Route("/health", health, methods=["GET"]), + Route("/", run, methods=["POST"]), + Route( + "/extensions/iac-code/v1/executions/{execution_id:str}/cancel", + cancel, + methods=["POST"], + ), + ], + lifespan=lifespan, + ) + app.state.run_adapter = run_adapter + return app + + +async def _event_stream(adapter: AguiA2AAdapter, ticket: RunTicket) -> AsyncIterator[str]: + encoder = EventEncoder() + iterator = adapter.stream(ticket).__aiter__() + next_item: asyncio.Task[Any] | None = None + + async def next_event() -> Any: + return await anext(iterator) + + try: + while True: + if next_item is None: + next_item = asyncio.create_task(next_event()) + done, _ = await asyncio.wait({next_item}, timeout=_HEARTBEAT_SECONDS) + if not done: + yield ": heartbeat\n\n" + continue + try: + item = next_item.result() + except StopAsyncIteration: + return + next_item = None + yield encoder.encode(item) + finally: + if next_item is not None and not next_item.done(): + next_item.cancel() + with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration): + await next_item + close_iterator = getattr(iterator, "aclose", None) + if close_iterator is not None: + with contextlib.suppress(Exception): + await close_iterator() + if not ticket.completed: + await adapter.disconnect(ticket) + + +def _authorize(request: Request, expected_token: str | None, *, language: str) -> JSONResponse | None: + if not expected_token: + return None + header = request.headers.get("authorization", "") + scheme, separator, token = header.partition(" ") + if not separator or scheme.lower() != "bearer" or not hmac.compare_digest(token, expected_token): + return _json_error( + 401, + "UNAUTHORIZED", + translate_message("A valid bearer token is required.", language=language), + ) + return None + + +class _BodyTooLargeError(Exception): + pass + + +async def _bounded_body(request: Request, limit: int) -> bytes: + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > limit: + raise _BodyTooLargeError + except ValueError: + pass + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > limit: + raise _BodyTooLargeError + return bytes(body) + + +def _json_error(status_code: int, code: str, message: str) -> JSONResponse: + return JSONResponse({"error": {"code": code, "message": message}}, status_code=status_code) + + +def _request_language(request: Request) -> str: + fallback = resolve_ui_language(None) + candidates: list[tuple[float, int, str]] = [] + for index, preference in enumerate(request.headers.get("accept-language", "").split(",")): + segments = [segment.strip() for segment in preference.split(";")] + value = segments[0] + quality = 1.0 + for parameter in segments[1:]: + name, separator, raw_quality = parameter.partition("=") + if separator and name.strip().lower() == "q": + try: + quality = float(raw_quality.strip()) + except ValueError: + quality = 0.0 + break + if not math.isfinite(quality) or quality <= 0 or quality > 1: + continue + language = fallback if value == "*" else normalize_agui_language(value, fallback="") + if language: + candidates.append((quality, -index, language)) + if candidates: + return max(candidates)[2] + return fallback + + +def _payload_language(payload: Mapping[str, Any], *, fallback: str) -> str: + forwarded_props = payload.get("forwardedProps") + if not isinstance(forwarded_props, Mapping): + return fallback + iac_code = forwarded_props.get("iacCode") + if not isinstance(iac_code, Mapping): + return fallback + return normalize_agui_language(iac_code.get("preferredLanguage"), fallback=fallback) + + +async def _monitor_idle( + adapter: AguiA2AAdapter, + idle_seconds: float, + request_shutdown: Callable[[], None], +) -> None: + interval = min(5.0, max(0.1, idle_seconds / 4)) + while True: + await asyncio.sleep(interval) + if adapter.is_idle and asyncio.get_running_loop().time() - adapter.last_activity >= idle_seconds: + request_shutdown() + return diff --git a/src/iac_code/agui/errors.py b/src/iac_code/agui/errors.py new file mode 100644 index 00000000..ead3fa7d --- /dev/null +++ b/src/iac_code/agui/errors.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from iac_code.i18n import SUPPORTED_LANGUAGES, translate_message + +# Keep the adapter's stable public error messages in the messages catalog while +# still translating them per request. Calling ``translate_message`` with +# English at import time is a no-op; its literal arguments are also extracted by +# Babel, and the allowlist prevents accidentally returning arbitrary upstream +# details to a caller in an unrelated locale. +_PUBLIC_AGUI_ERROR_MESSAGES = frozenset( + { + translate_message("A new run requires a user message.", language="en"), + translate_message("A resolved interrupt requires a payload.", language="en"), + translate_message("An image exceeds the maximum size.", language="en"), + translate_message("An interrupt belongs to another A2A context.", language="en"), + translate_message("An interrupt belongs to another A2A task.", language="en"), + translate_message("Client-provided tools are not supported.", language="en"), + translate_message("Invalid iac-code forwarded properties.", language="en"), + translate_message("Only text and inline data images are supported.", language="en"), + translate_message("Remote media URLs are not supported.", language="en"), + translate_message("The A2A context identity changed unexpectedly.", language="en"), + translate_message("The A2A execution failed.", language="en"), + translate_message("The A2A interrupt response was not accepted.", language="en"), + translate_message("The A2A permission response was not accepted.", language="en"), + translate_message("The A2A task identity changed unexpectedly.", language="en"), + translate_message("The A2A task identity does not match the interrupted run.", language="en"), + translate_message("The A2A task to resume is unavailable.", language="en"), + translate_message("The AG-UI adapter state is unavailable.", language="en"), + translate_message("The AG-UI run id has already been used.", language="en"), + translate_message("The AG-UI thread already has an active run.", language="en"), + translate_message( + "The AG-UI thread is already bound to another workspace or caller.", + language="en", + ), + translate_message("The AG-UI thread is waiting for interrupt responses.", language="en"), + translate_message("The accepted interrupt response could not be committed.", language="en"), + translate_message("The execution mapping could not be committed.", language="en"), + translate_message("The execution session mapping could not be committed.", language="en"), + translate_message("The execution to resume is no longer available.", language="en"), + translate_message("The execution was cancelled by the interrupt response.", language="en"), + translate_message("The execution was cancelled.", language="en"), + translate_message("The iac-code session identity changed unexpectedly.", language="en"), + translate_message("The iac-code session to resume is unavailable.", language="en"), + translate_message("The iac-code workspace cannot be created.", language="en"), + translate_message("The iac-code workspace is invalid.", language="en"), + translate_message("The iac-code workspace is not a directory.", language="en"), + translate_message("The iac-code workspace is outside the allowed roots.", language="en"), + translate_message("The iac-code workspace must be an absolute path.", language="en"), + translate_message("The image data is invalid.", language="en"), + translate_message("The image data is not valid base64.", language="en"), + translate_message("The image media type is not supported.", language="en"), + translate_message("The interrupt response does not contain an answer.", language="en"), + translate_message("The interrupt response has already been applied.", language="en"), + translate_message("The interrupt response payload is invalid.", language="en"), + translate_message("The interrupted execution has expired.", language="en"), + translate_message("The interrupted execution state could not be committed.", language="en"), + translate_message("The local A2A execution service is unavailable.", language="en"), + translate_message( + "The local A2A execution service rejected the interrupt response.", + language="en", + ), + translate_message("The resume contains duplicate interrupt ids.", language="en"), + translate_message( + "The resume must resolve every pending interrupt exactly once.", + language="en", + ), + translate_message("The resume references an unknown interrupt.", language="en"), + translate_message("The resume request does not match the interrupted run.", language="en"), + translate_message("The total image content exceeds the maximum size.", language="en"), + translate_message("The user message content is invalid.", language="en"), + } +) + + +def translate_agui_error(message: str, *, language: str) -> str: + """Translate one stable public adapter error without process-global locale state.""" + + if message not in _PUBLIC_AGUI_ERROR_MESSAGES: + return message + return translate_message(message, language=normalize_agui_language(language)) + + +def normalize_agui_language(value: object, *, fallback: str = "en") -> str: + """Resolve a language tag to one supported request-local messages catalog.""" + + if isinstance(value, str): + language = value.strip().lower().replace("_", "-").split("-", 1)[0] + if language in SUPPORTED_LANGUAGES: + return language + return fallback + + +@dataclass(frozen=True) +class AguiError(Exception): + code: str + message: str + + def __str__(self) -> str: + return self.message + + +@dataclass(frozen=True) +class AdmissionError(Exception): + code: str + message: str + status_code: int = 409 + + def __str__(self) -> str: + return self.message diff --git a/src/iac_code/agui/events.py b/src/iac_code/agui/events.py new file mode 100644 index 00000000..abf85268 --- /dev/null +++ b/src/iac_code/agui/events.py @@ -0,0 +1,933 @@ +"""Translate public A2A wire events into standard AG-UI events.""" + +from __future__ import annotations + +import hashlib +import json +import math +import time +import uuid +from collections.abc import Iterable, Mapping +from datetime import datetime, timedelta, timezone +from typing import Any, cast + +from ag_ui.core import ( + ActivitySnapshotEvent, + CustomEvent, + Interrupt, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + StepFinishedEvent, + StepStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + TokenUsage, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) + +from iac_code.agui.errors import normalize_agui_language +from iac_code.i18n import translate_message + +# Keep the external AG-UI stream focused on Pipeline information that has no +# equivalent standard AG-UI event and is useful to a client. The A2A stream +# remains full fidelity; this allowlist only controls its AG-UI projection. +_AGUI_PIPELINE_CUSTOM_EVENT_TYPES = frozenset( + { + "backup_blocked", + "candidate_completed", + "candidate_detail_shown", + "candidate_failed", + "candidate_interrupted", + "candidate_restart_requested", + "candidate_selected", + "candidate_started", + "candidate_step_failed", + "cleanup_completed", + "cleanup_failed", + "cleanup_progress", + "cleanup_started", + "context_compacted", + "context_compaction_failed", + "context_compaction_started", + "diagram_shown", + "fields_marked_stale", + "mcp_status", + "pipeline_completed", + "pipeline_error", + "pipeline_resumed", + "pipeline_started", + "pipeline_warning", + "rollback_completed", + "rollback_triggered", + "stack_current_changed", + "stack_instances_progress", + "stack_progress", + "step_failed", + "sub_pipeline_completed", + "sub_pipeline_started", + "sub_step_failed", + "tool_progress", + } +) + + +def timestamp_ms(timestamp: float | None = None) -> int: + return int((time.time() if timestamp is None else timestamp) * 1000) + + +def normalize_a2a_state(value: Any) -> str: + if not isinstance(value, str): + return "" + return value.strip().lower().removeprefix("task_state_").replace("_", "-") + + +def a2a_result(payload: Any) -> dict[str, Any]: + if not isinstance(payload, Mapping): + return {} + result = payload.get("result") + if isinstance(result, Mapping): + for key in ("task", "statusUpdate", "artifactUpdate", "message"): + nested = result.get(key) + if isinstance(nested, Mapping): + return dict(nested) + return dict(result) + return dict(payload) + + +def a2a_task_id(payload: Any) -> str | None: + result = a2a_result(payload) + for key in ("taskId", "id"): + value = result.get(key) + if isinstance(value, str) and value: + return value + return None + + +def a2a_context_id(payload: Any) -> str | None: + result = a2a_result(payload) + value = result.get("contextId") + return value if isinstance(value, str) and value else None + + +def a2a_state(payload: Any) -> str: + result = a2a_result(payload) + status = result.get("status") + if not isinstance(status, Mapping): + return "" + return normalize_a2a_state(status.get("state")) + + +def a2a_iac_code_metadata(payload: Any) -> dict[str, Any]: + result = a2a_result(payload) + metadata = result.get("metadata") + if not isinstance(metadata, Mapping): + return {} + value = metadata.get("iac_code") + return dict(value) if isinstance(value, Mapping) else {} + + +def a2a_iac_code_session_id(payload: Any) -> str | None: + metadata = a2a_iac_code_metadata(payload) + candidates: list[Any] = [metadata.get("iacCodeSessionId")] + pipeline = metadata.get("pipeline") + if isinstance(pipeline, Mapping): + candidates.append(pipeline.get("iacCodeSessionId")) + pipeline_batch = metadata.get("pipelineBatch") + if isinstance(pipeline_batch, Mapping) and isinstance(pipeline_batch.get("events"), list): + candidates.extend( + event.get("iacCodeSessionId") for event in pipeline_batch["events"] if isinstance(event, Mapping) + ) + return next((value for value in candidates if isinstance(value, str) and value), None) + + +def a2a_input(payload: Any) -> dict[str, Any] | None: + values = a2a_inputs(payload) + return values[0] if values else None + + +def a2a_inputs(payload: Any) -> list[dict[str, Any]]: + """Return direct and task-snapshot input projections without duplicates.""" + metadata = a2a_iac_code_metadata(payload) + candidates: list[Any] = [metadata.get("input")] + pending_permissions = metadata.get("pendingPermissions") + if isinstance(pending_permissions, list): + candidates.extend(pending_permissions) + output: list[dict[str, Any]] = [] + seen: set[str] = set() + for candidate in candidates: + if not isinstance(candidate, Mapping) or candidate.get("required") is not True: + continue + value = dict(candidate) + input_id = str(value.get("inputId") or "") + dedupe_key = input_id or json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + output.append(value) + return output + + +def a2a_sideband_input_ids(payload: Any) -> set[str]: + """Return permission ids exposed as concurrent Pipeline waits.""" + + metadata = a2a_iac_code_metadata(payload) + output: set[str] = set() + direct = metadata.get("input") + if isinstance(direct, Mapping) and ( + direct.get("scope") == "candidate" or isinstance(direct.get("subPipelineId"), str) + ): + input_id = direct.get("inputId") + if isinstance(input_id, str) and input_id: + output.add(input_id) + pending_permissions = metadata.get("pendingPermissions") + if not isinstance(pending_permissions, list): + return output + output.update( + str(value["inputId"]) + for value in pending_permissions + if isinstance(value, Mapping) and isinstance(value.get("inputId"), str) and value["inputId"] + ) + return output + + +def interrupt_from_a2a(value: Mapping[str, Any], *, ttl_seconds: int) -> Interrupt: + kind = str(value.get("kind") or "input_required") + language = normalize_agui_language(value.get("language")) + input_id = str(value.get("inputId") or f"input-{uuid.uuid4().hex}") + tool_use_id = _string(value.get("toolUseId")) + raw_options = value.get("options") + options = _standard_options( + raw_options if isinstance(raw_options, list) else [], + pipeline=kind == "candidate_selection", + ) + if kind == "permission": + schema = { + "type": "object", + "properties": {"decision": {"type": "string", "enum": ["allow_once", "deny"]}}, + "required": ["decision"], + "additionalProperties": False, + } + message = str( + value.get("prompt") or value.get("title") or translate_message("Permission required", language=language) + ) + reason = "tool_call" + else: + allow_free_text = bool(value.get("allowFreeText")) or not options + schema = _selection_schema(options, allow_free_text=allow_free_text) + message = _selection_message( + str(value.get("prompt") or translate_message("Input required", language=language)), + options, + ) + reason = "input_required" + return Interrupt( + id=input_id, + reason=reason, + message=message, + tool_call_id=tool_use_id, + response_schema=schema, + expires_at=(datetime.now(timezone.utc) + timedelta(seconds=max(1, ttl_seconds))) + .isoformat() + .replace("+00:00", "Z"), + metadata={"schemaVersion": 1, **dict(value), "standardOptions": options}, + ) + + +class A2AEventMapper: + """Stateful A2A-wire mapper that keeps AG-UI spans balanced.""" + + def __init__( + self, + *, + thread_id: str, + run_id: str, + open_pipeline_steps: set[str] | None = None, + text_snapshot_digests: set[str] | None = None, + ) -> None: + self.thread_id = thread_id + self.run_id = run_id + self.current_message_id: str | None = None + self.open_messages: set[str] = set() + self.open_reasoning: set[str] = set() + self.open_tools: set[str] = set() + self.seen_tool_results: set[str] = set() + self.usage: list[TokenUsage] = [] + self.seen_pipeline_event_ids: set[str] = set() + # ``open_pipeline_steps`` is the durable A2A/Pipeline state. AG-UI + # STEP spans, however, belong to one RUN_STARTED/RUN_FINISHED pair and + # cannot remain open across an interrupt. Keep the two lifecycles + # separate so an interrupted Pipeline can be resumed without emitting + # an invalid AG-UI stream. + self.open_pipeline_steps: set[str] = set(open_pipeline_steps or ()) + self.run_pipeline_steps: dict[str, str] = {} + self.pipeline_run_id = f"pipeline-{thread_id}" + self.last_pipeline_sequence = 0 + self.text_emitted = False + self.text_snapshot_digests: set[str] = set(text_snapshot_digests or ()) + self._message_text_parts: dict[str, list[str]] = {} + self._resume_prefix_checked: set[str] = set() + + def session_event( + self, + *, + execution_id: str, + context_id: str, + task_id: str | None, + ros_invocation_id: str, + session_id: str | None, + ) -> CustomEvent: + return CustomEvent( + name="iac-code.session.v1", + value={ + "schemaVersion": 1, + "threadId": self.thread_id, + "aguiRunId": self.run_id, + "executionId": execution_id, + "contextId": context_id, + "taskId": task_id, + "rosInvocationId": ros_invocation_id, + "sessionId": session_id, + }, + timestamp=timestamp_ms(), + ) + + def map( + self, + payload: Any, + *, + include_pipeline: bool = True, + include_status_text: bool = True, + ) -> list[Any]: + result = a2a_result(payload) + iac_code = a2a_iac_code_metadata(payload) + output: list[Any] = [] + + if include_pipeline: + pipeline_batch = iac_code.get("pipelineBatch") + if isinstance(pipeline_batch, Mapping) and isinstance(pipeline_batch.get("events"), list): + for envelope in pipeline_batch["events"]: + if isinstance(envelope, Mapping): + output.extend(self._map_pipeline(dict(envelope))) + pipeline = iac_code.get("pipeline") + if isinstance(pipeline, Mapping): + output.extend(self._map_pipeline(dict(pipeline))) + + thinking = iac_code.get("thinking") + if isinstance(thinking, Mapping): + output.extend(self._map_thinking(thinking)) + tool = iac_code.get("tool") + if isinstance(tool, Mapping): + output.extend(self._map_tool(tool)) + usage = iac_code.get("usage") + if isinstance(usage, Mapping): + self._record_usage(usage) + + artifact = result.get("artifact") + if isinstance(artifact, Mapping): + output.append( + CustomEvent( + name="iac-code.artifact.v1", + value={"schemaVersion": 1, **dict(artifact)}, + timestamp=timestamp_ms(), + ) + ) + + text = _status_text(result) if include_status_text else "" + history_message_id: str | None = None + if include_status_text and not text and not self.text_emitted: + text, history_message_id = _task_history_agent_text(result) + assistant_final = iac_code.get("assistantFinal") + duplicate_final = isinstance(assistant_final, Mapping) and assistant_final.get("complete") is True + status_message_id = _status_message_id(result) or history_message_id + if text and not (duplicate_final and self.text_emitted): + output.extend(self._map_status_text(text, status_message_id)) + return output + + def _map_status_text(self, text: str, message_id: str | None) -> list[Any]: + """Map live status text while removing a cumulative Resume prefix.""" + + replay_key = message_id or self.current_message_id + if replay_key is None or replay_key in self._resume_prefix_checked: + return self._map_text(text, message_id) + self._resume_prefix_checked.add(replay_key) + + prefix_length = _replayed_prefix_length(text, self.text_snapshot_digests) + if prefix_length == 0: + return self._map_text(text, message_id) + + # A resumed A2A agent can replay all text produced before an interrupt + # and append the newly generated suffix in one status message. Keep the + # full value as the next durable snapshot, but expose only the suffix as + # an AG-UI delta. The persisted digest is sufficient to locate the old + # prefix, so adapter state does not need to store conversation text. + self._message_text_parts[replay_key] = [text] + suffix = text[prefix_length:] + if not suffix: + return [] + return self._map_text(suffix, message_id, record_snapshot=False) + + def _map_text( + self, + text: str, + message_id: str | None, + *, + allow_parallel: bool = False, + record_snapshot: bool = True, + ) -> list[Any]: + output = self.close_reasoning() + resolved_id = message_id or self.current_message_id or f"assistant-{uuid.uuid4().hex}" + if resolved_id not in self.open_messages: + if not allow_parallel and self.current_message_id is not None: + output.extend(self.close_text(self.current_message_id)) + self.open_messages.add(resolved_id) + output.append(TextMessageStartEvent(message_id=resolved_id, timestamp=timestamp_ms())) + self.current_message_id = resolved_id + output.append(TextMessageContentEvent(message_id=resolved_id, delta=text, timestamp=timestamp_ms())) + if record_snapshot: + self._message_text_parts.setdefault(resolved_id, []).append(text) + self.text_emitted = True + return output + + def _map_thinking(self, value: Mapping[str, Any], message_id: str | None = None) -> list[Any]: + if value.get("type") != "raw_thinking": + return [] + text = value.get("text") + if not isinstance(text, str) or not text: + return [] + reasoning_id = f"reasoning-{message_id or self.current_message_id or self.run_id}" + output: list[Any] = [] + if reasoning_id not in self.open_reasoning: + self.open_reasoning.add(reasoning_id) + output.extend( + [ + ReasoningStartEvent(message_id=reasoning_id, timestamp=timestamp_ms()), + ReasoningMessageStartEvent(message_id=reasoning_id, role="reasoning", timestamp=timestamp_ms()), + ] + ) + output.append(ReasoningMessageContentEvent(message_id=reasoning_id, delta=text, timestamp=timestamp_ms())) + return output + + def _map_tool(self, value: Mapping[str, Any]) -> list[Any]: + status = str(value.get("status") or "") + tool_id = _string(value.get("toolUseId")) or f"tool-{uuid.uuid4().hex}" + name = _string(value.get("name")) or "tool" + output: list[Any] = [] + if status in {"started", "input_complete"} and self.current_message_id is not None: + self._finalize_text_snapshot(self.current_message_id) + if status in {"started", "input_complete"} and tool_id not in self.open_tools: + self.open_tools.add(tool_id) + output.append( + ToolCallStartEvent( + tool_call_id=tool_id, + tool_call_name=name, + parent_message_id=self.current_message_id, + timestamp=timestamp_ms(), + ) + ) + if status == "input_complete": + raw_input = value.get("toolInput") + if raw_input is None: + raw_input = {"summary": value.get("inputSummary")} + output.extend( + [ + ToolCallArgsEvent( + tool_call_id=tool_id, + delta=json.dumps(raw_input, ensure_ascii=False, separators=(",", ":"), sort_keys=True), + timestamp=timestamp_ms(), + ), + ToolCallEndEvent(tool_call_id=tool_id, timestamp=timestamp_ms()), + ] + ) + self.open_tools.discard(tool_id) + elif status in {"completed", "failed"}: + if tool_id in self.seen_tool_results: + return output + self.seen_tool_results.add(tool_id) + result = value.get("result") + output.append( + ToolCallResultEvent( + message_id=f"tool-result-{tool_id}", + tool_call_id=tool_id, + content=_json_text(result), + role="tool", + timestamp=timestamp_ms(), + ) + ) + elif status == "progress": + output.append( + CustomEvent( + name="iac-code.tool-progress.v1", + value={"schemaVersion": 1, **dict(value)}, + timestamp=timestamp_ms(), + ) + ) + return output + + def map_resolved_tool(self, *, tool_call_id: str, content: Any) -> list[Any]: + """Close an input-required tool span after A2A accepts its answer.""" + + return self._map_tool( + { + "status": "completed", + "toolUseId": tool_call_id, + "result": content, + } + ) + + def _map_pipeline(self, envelope: dict[str, Any]) -> list[Any]: + event_id = _string(envelope.get("eventId")) + if event_id and event_id in self.seen_pipeline_event_ids: + return [] + sequence = _integer(envelope.get("sequence")) or 0 + if sequence and sequence <= self.last_pipeline_sequence: + return [] + if event_id: + self.seen_pipeline_event_ids.add(event_id) + event_type = str(envelope.get("eventType") or "pipeline_event") + self.last_pipeline_sequence = max(self.last_pipeline_sequence, sequence) + step = envelope.get("candidateStep") or envelope.get("step") + step_id = _string(step.get("id")) if isinstance(step, Mapping) else None + output = self._map_pipeline_standard_event(envelope) + output.extend(self._map_pipeline_step_lifecycle(envelope, event_type=event_type, step_id=step_id)) + custom = _pipeline_custom_event(envelope, event_type=event_type) + if custom is not None: + output.append(custom) + return output + + def map_pipeline_recovery(self, state: Mapping[str, Any]) -> list[Any]: + """Project a full A2A snapshot plus only post-disconnect incremental events.""" + + snapshot = state.get("snapshot") + if not isinstance(snapshot, Mapping): + return [] + snapshot_value = dict(snapshot) + pipeline_run_id = _string(snapshot_value.get("pipelineRunId")) or self.pipeline_run_id + self.pipeline_run_id = pipeline_run_id + snapshot_sequence = _integer(snapshot_value.get("lastSequence")) or 0 + output: list[Any] = [ + ActivitySnapshotEvent( + message_id=f"pipeline:{pipeline_run_id}", + activity_type="iac-code.pipeline.v1", + content={"schemaVersion": 1, "pipelineRunId": pipeline_run_id, "snapshot": snapshot_value}, + replace=True, + timestamp=timestamp_ms(), + ), + ] + raw_events = state.get("events") + if not isinstance(raw_events, list): + self.last_pipeline_sequence = max(self.last_pipeline_sequence, snapshot_sequence) + return output + for raw_event in raw_events: + if not isinstance(raw_event, Mapping): + continue + envelope = dict(raw_event) + event_id = _string(envelope.get("eventId")) + if event_id and event_id in self.seen_pipeline_event_ids: + continue + sequence = _integer(envelope.get("sequence")) or 0 + if not event_id and sequence and sequence <= self.last_pipeline_sequence: + continue + if event_id: + self.seen_pipeline_event_ids.add(event_id) + self.last_pipeline_sequence = max( + self.last_pipeline_sequence, + sequence, + ) + output.extend(self._map_recovery_pipeline_event(envelope)) + self.last_pipeline_sequence = max(self.last_pipeline_sequence, snapshot_sequence) + return output + + def _map_recovery_pipeline_event(self, envelope: dict[str, Any]) -> list[Any]: + event_type = str(envelope.get("eventType") or "pipeline_event") + step = envelope.get("candidateStep") or envelope.get("step") + step_id = _string(step.get("id")) if isinstance(step, Mapping) else None + output = self._map_pipeline_standard_event(envelope) + output.extend(self._map_pipeline_step_lifecycle(envelope, event_type=event_type, step_id=step_id)) + custom = _pipeline_custom_event(envelope, event_type=event_type) + if custom is not None: + output.append(custom) + return output + + def _map_pipeline_step_lifecycle( + self, + envelope: Mapping[str, Any], + *, + event_type: str, + step_id: str | None, + ) -> list[Any]: + if not step_id: + return [] + step_key = _pipeline_step_key(envelope, step_id) + if event_type in {"step_started", "candidate_step_started", "input_received"}: + self.open_pipeline_steps.add(step_key) + if step_key in self.run_pipeline_steps: + return [] + step_name = _pipeline_step_name(step_key, step_id=step_id) + self.run_pipeline_steps[step_key] = step_name + return [StepStartedEvent(step_name=step_name, timestamp=timestamp_ms())] + if event_type in { + "step_completed", + "step_failed", + "candidate_step_completed", + "candidate_step_failed", + }: + if step_key not in self.open_pipeline_steps: + return [] + self.open_pipeline_steps.remove(step_key) + step_name = self.run_pipeline_steps.pop(step_key, None) + if step_name is not None: + return [StepFinishedEvent(step_name=step_name, timestamp=timestamp_ms())] + # Recovery can observe a completion for a durable step before the + # caller explicitly reopens it. Emit a balanced zero-length span + # rather than an orphan STEP_FINISHED event. + step_name = _pipeline_step_name(step_key, step_id=step_id) + now = timestamp_ms() + return [ + StepStartedEvent(step_name=step_name, timestamp=now), + StepFinishedEvent(step_name=step_name, timestamp=now), + ] + return [] + + def reopen_pipeline_steps(self) -> list[Any]: + """Open AG-UI spans for durable Pipeline steps in a new run.""" + + output: list[Any] = [] + for step_key in sorted(self.open_pipeline_steps): + if step_key in self.run_pipeline_steps: + continue + step_name = _pipeline_step_name(step_key) + self.run_pipeline_steps[step_key] = step_name + output.append(StepStartedEvent(step_name=step_name, timestamp=timestamp_ms())) + return output + + def _map_pipeline_standard_event(self, envelope: Mapping[str, Any]) -> list[Any]: + event_type = str(envelope.get("eventType") or "") + data = envelope.get("data") + if not isinstance(data, Mapping): + return [] + if event_type == "text_delta": + text = data.get("text") + if isinstance(text, str) and text: + return self._map_text( + text, + _pipeline_message_id(envelope, self.pipeline_run_id), + allow_parallel=True, + ) + if event_type == "thinking_delta": + return self._map_thinking(data, _pipeline_message_id(envelope, self.pipeline_run_id)) + if event_type == "tool_started": + return self._map_tool( + { + "status": "input_complete", + "toolUseId": data.get("toolUseId"), + "name": data.get("toolName"), + "toolInput": data.get("input"), + } + ) + if event_type == "tool_result": + return self._map_tool( + { + "status": "failed" if data.get("isError") is True else "completed", + "toolUseId": data.get("toolUseId"), + "name": data.get("toolName"), + "result": data.get("result"), + } + ) + if event_type == "usage": + self._record_usage(data) + return [] + + def _record_usage(self, usage: Mapping[str, Any]) -> None: + self.usage.append( + TokenUsage( + provider=_string(usage.get("provider")), + model=_string(usage.get("model")), + input_tokens=_integer(usage.get("inputTokens")), + output_tokens=_integer(usage.get("outputTokens")), + total_tokens=_integer(usage.get("totalTokens")), + cached_input_tokens=_integer(usage.get("cachedInputTokens")), + ) + ) + + def close_reasoning(self) -> list[Any]: + output: list[Any] = [] + for reasoning_id in sorted(self.open_reasoning): + output.extend( + [ + ReasoningMessageEndEvent(message_id=reasoning_id, timestamp=timestamp_ms()), + ReasoningEndEvent(message_id=reasoning_id, timestamp=timestamp_ms()), + ] + ) + self.open_reasoning.clear() + return output + + def close_text(self, message_id: str | None = None) -> list[Any]: + if message_id is not None: + message_ids = [message_id] if message_id in self.open_messages else [] + else: + message_ids = sorted(self.open_messages) + if not message_ids: + return [] + for value in message_ids: + self._finalize_text_snapshot(value) + self.open_messages.difference_update(message_ids) + if self.current_message_id in message_ids: + self.current_message_id = next(iter(self.open_messages), None) + return [TextMessageEndEvent(message_id=value, timestamp=timestamp_ms()) for value in message_ids] + + def finalize_text_snapshots(self) -> None: + for message_id in list(self._message_text_parts): + self._finalize_text_snapshot(message_id) + + def _finalize_text_snapshot(self, message_id: str) -> None: + parts = self._message_text_parts.pop(message_id, None) + if parts: + self.text_snapshot_digests.add(_text_digest("".join(parts))) + + def close_all(self) -> list[Any]: + output = self.close_reasoning() + for tool_id in sorted(self.open_tools): + output.append(ToolCallEndEvent(tool_call_id=tool_id, timestamp=timestamp_ms())) + self.open_tools.clear() + output.extend(self.close_text()) + for step_key in sorted(self.run_pipeline_steps): + output.append( + StepFinishedEvent( + step_name=self.run_pipeline_steps[step_key], + timestamp=timestamp_ms(), + ) + ) + self.run_pipeline_steps.clear() + return output + + +def aggregate_usage(items: Iterable[TokenUsage]) -> list[TokenUsage] | None: + values = list(items) + if not values: + return None + grouped: dict[tuple[str | None, str | None], list[TokenUsage]] = {} + for item in values: + grouped.setdefault((item.provider, item.model), []).append(item) + return [ + TokenUsage( + provider=provider, + model=model, + input_tokens=sum(item.input_tokens or 0 for item in group), + output_tokens=sum(item.output_tokens or 0 for item in group), + total_tokens=sum(item.total_tokens or 0 for item in group), + cached_input_tokens=sum(item.cached_input_tokens or 0 for item in group), + ) + for (provider, model), group in grouped.items() + ] + + +def _pipeline_custom_event(envelope: dict[str, Any], *, event_type: str) -> CustomEvent | None: + if event_type not in _AGUI_PIPELINE_CUSTOM_EVENT_TYPES: + return None + return CustomEvent(name="iac-code.pipeline.v1", value=envelope, timestamp=timestamp_ms()) + + +def resume_value(input_value: Mapping[str, Any], payload: Any) -> str: + if not isinstance(payload, Mapping): + return "" + free_text = payload.get("freeText") or payload.get("free_text") + if isinstance(free_text, str) and free_text: + return free_text + selected_id = payload.get("selectedId") or payload.get("selected_id") + if not isinstance(selected_id, str) or not selected_id: + return "" + raw_options = input_value.get("options") + if isinstance(raw_options, list): + for option in raw_options: + if not isinstance(option, Mapping): + continue + option_id = option.get("id") + if str(option_id) != selected_id: + continue + label = option.get("label") or option.get("name") or option_id + return str(label) + return selected_id + + +def _standard_options(raw_options: list[Any], *, pipeline: bool) -> list[dict[str, str]]: + del pipeline + output: list[dict[str, str]] = [] + used_ids: set[str] = set() + for index, option in enumerate(raw_options): + if isinstance(option, Mapping): + option_id = str(option.get("id", option.get("candidate_index", index))) + title = str(option.get("label") or option.get("name") or option.get("title") or option_id) + else: + option_id = str(option) + title = str(option) + base_id = option_id or f"option-{index + 1}" + option_id = base_id + suffix = 2 + while option_id in used_ids: + option_id = f"{base_id}-{suffix}" + suffix += 1 + used_ids.add(option_id) + output.append({"id": option_id, "title": title}) + return output + + +def _selection_schema(options: list[dict[str, str]], *, allow_free_text: bool) -> dict[str, Any]: + branches: list[dict[str, Any]] = [ + { + "type": "object", + "properties": {"selectedId": {"type": "string", "const": option["id"], "title": option["title"]}}, + "required": ["selectedId"], + "additionalProperties": False, + } + for option in options + ] + if allow_free_text: + branches.append( + { + "type": "object", + "properties": {"freeText": {"type": "string", "minLength": 1}}, + "required": ["freeText"], + "additionalProperties": False, + } + ) + return {"oneOf": branches} + + +def _selection_message(prompt: str, options: list[dict[str, str]]) -> str: + if not options: + return prompt + return "{}\n{}".format(prompt, "\n".join(f"- {item['id']}: {item['title']}" for item in options)) + + +def _status_text(result: Mapping[str, Any]) -> str: + status = result.get("status") + if not isinstance(status, Mapping): + return "" + message = status.get("message") + if not isinstance(message, Mapping) or message.get("role") not in {"ROLE_AGENT", "agent", None}: + return "" + parts = message.get("parts") + if not isinstance(parts, list): + return "" + return "".join( + str(part.get("text")) for part in parts if isinstance(part, Mapping) and isinstance(part.get("text"), str) + ) + + +def _status_message_id(result: Mapping[str, Any]) -> str | None: + status = result.get("status") + message = status.get("message") if isinstance(status, Mapping) else None + return _string(message.get("messageId")) if isinstance(message, Mapping) else None + + +def _task_history_agent_text(result: Mapping[str, Any]) -> tuple[str, str | None]: + history = result.get("history") + if not isinstance(history, list): + return "", None + trailing: list[Mapping[str, Any]] = [] + for raw_message in reversed(history): + if not isinstance(raw_message, Mapping): + break + message = cast(Mapping[str, Any], raw_message) + role = message.get("role") + if role not in {"ROLE_AGENT", "agent"}: + if trailing: + break + continue + trailing.append(message) + trailing.reverse() + pieces: list[str] = [] + message_id: str | None = None + for message in trailing: + message_id = _string(message.get("messageId")) or message_id + parts = message.get("parts") + if not isinstance(parts, list): + continue + pieces.extend( + str(part.get("text")) for part in parts if isinstance(part, Mapping) and isinstance(part.get("text"), str) + ) + return "".join(pieces), message_id + + +def _json_text(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _text_digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _replayed_prefix_length(value: str, snapshot_digests: set[str]) -> int: + """Return the longest prefix already emitted before an interrupt.""" + + if not value or not snapshot_digests: + return 0 + digest = hashlib.sha256() + longest = 0 + for index, character in enumerate(value, start=1): + digest.update(character.encode("utf-8")) + if digest.hexdigest() in snapshot_digests: + longest = index + return longest + + +def _string(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _integer(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and math.isfinite(value) and value.is_integer(): + return int(value) + return None + + +def _pipeline_step_key(envelope: Mapping[str, Any], step_id: str) -> str: + candidate = envelope.get("candidate") + if isinstance(candidate, Mapping): + candidate_id = ( + _string(candidate.get("id")) or _string(candidate.get("subPipelineId")) or _string(candidate.get("runId")) + ) + if candidate_id: + return f"candidate:{candidate_id}:{step_id}" + return f"step:{step_id}" + + +def _pipeline_step_name(step_key: str, *, step_id: str | None = None) -> str: + """Return a RUN-local AG-UI step name unique across parallel candidates.""" + + if step_key.startswith("step:"): + return step_id or step_key.removeprefix("step:") + # Candidate run ids are stable across start/completion/recovery and make + # same-named parallel candidate steps legal under AG-UI's step verifier. + return step_key + + +def _pipeline_message_id(envelope: Mapping[str, Any], pipeline_run_id: str) -> str: + scope = _string(envelope.get("scope")) or "pipeline" + coordinates: list[str] = [] + # A candidate step id (for example ``template_generating``) is shared by + # every parallel candidate. Include the candidate run identity first so + # independent sub-pipeline text streams never collapse into one AG-UI + # message. + for key in ("candidate", "candidateStep", "step"): + coordinate = envelope.get(key) + if not isinstance(coordinate, Mapping): + continue + run_id = _string(coordinate.get("runId")) or _string(coordinate.get("id")) + if run_id and run_id not in coordinates: + coordinates.append(run_id) + if coordinates: + return f"pipeline-message:{pipeline_run_id}:{scope}:{':'.join(coordinates)}" + return f"pipeline-message:{pipeline_run_id}:{scope}" diff --git a/src/iac_code/agui/inputs.py b/src/iac_code/agui/inputs.py new file mode 100644 index 00000000..a147c626 --- /dev/null +++ b/src/iac_code/agui/inputs.py @@ -0,0 +1,190 @@ +"""Strict request validation for the AG-UI to A2A protocol boundary.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Literal + +from ag_ui.core import RunAgentInput +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from iac_code.agui.errors import AguiError +from iac_code.utils.image.resizer import maybe_resize_and_downsample + +MAX_REQUEST_BYTES = 12 * 1024 * 1024 +MAX_IMAGE_BYTES = 8 * 1024 * 1024 +MAX_TOTAL_IMAGE_BYTES = 10 * 1024 * 1024 +SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) + + +class StrictModel(BaseModel): + model_config = ConfigDict(alias_generator=None, populate_by_name=True, extra="forbid", strict=True) + + +class ThinkingOptions(StrictModel): + enabled: bool = False + effort: str | None = None + budget: int | None = Field(default=None, gt=0) + + +class AlibabaCloudOptions(StrictModel): + access_key_id: str | None = Field(default=None, alias="accessKeyId", repr=False) + access_key_secret: str | None = Field(default=None, alias="accessKeySecret", repr=False) + security_token: str | None = Field(default=None, alias="securityToken", repr=False) + region_id: str | None = Field(default=None, alias="regionId") + + +class IacCodeForwardedProps(StrictModel): + schema_version: Literal[1] = Field(alias="schemaVersion") + ros_invocation_id: str = Field(alias="rosInvocationId", min_length=1, max_length=256) + cwd: str + model: str | None = None + llm_api_key: str | None = Field(default=None, alias="llmApiKey", repr=False) + thinking: ThinkingOptions | None = None + user_id: str | None = Field(default=None, alias="userId") + channel: str | None = None + preferred_language: str | None = Field(default=None, alias="preferredLanguage") + candidate_presentation: Literal["standard", "rich"] | None = Field( + default=None, + alias="candidatePresentation", + ) + run_mode: Literal["normal", "pipeline"] | None = Field(default=None, alias="runMode") + pipeline_name: str | None = Field(default=None, alias="pipelineName") + cleanup_only: bool = Field(default=False, alias="cleanupOnly") + alibaba_cloud: AlibabaCloudOptions | None = Field(default=None, alias="alibabaCloud", repr=False) + + +class ForwardedProps(StrictModel): + iac_code: IacCodeForwardedProps = Field(alias="iacCode") + + +def parse_run_input(payload: Any) -> RunAgentInput: + try: + return RunAgentInput.model_validate(payload) + except ValidationError as exc: + raise ValueError("Invalid AG-UI RunAgentInput envelope.") from exc + + +def parse_forwarded_props(value: Any) -> ForwardedProps: + try: + return ForwardedProps.model_validate(value) + except ValidationError as exc: + raise AguiError("INVALID_INPUT", "Invalid iac-code forwarded properties.") from exc + + +def canonical_digest(payload: Any) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def validate_tools(run_input: RunAgentInput) -> None: + if run_input.tools: + raise AguiError("INVALID_INPUT", "Client-provided tools are not supported.") + + +def resolve_cwd(raw_cwd: str) -> str: + """Resolve a per-request workspace without allowing a symlink escape.""" + if not raw_cwd or not Path(raw_cwd).expanduser().is_absolute(): + raise AguiError("INVALID_INPUT", "The iac-code workspace must be an absolute path.") + logical = os.path.normpath(os.path.expandvars(os.path.expanduser(raw_cwd))) + try: + resolved = Path(logical).resolve(strict=False) + except OSError as exc: + raise AguiError("INVALID_INPUT", "The iac-code workspace is invalid.") from exc + + roots = allowed_cwd_roots() + if not any(_is_relative_to(resolved, root) for root in roots): + raise AguiError("INVALID_INPUT", "The iac-code workspace is outside the allowed roots.") + if resolved.exists() and not resolved.is_dir(): + raise AguiError("INVALID_INPUT", "The iac-code workspace is not a directory.") + if not resolved.exists(): + try: + resolved.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise AguiError("INVALID_INPUT", "The iac-code workspace cannot be created.") from exc + return str(resolved) + + +def allowed_cwd_roots() -> list[Path]: + raw = os.environ.get("IAC_CODE_AGUI_ALLOWED_CWDS") or os.environ.get("IACCODE_A2A_ALLOWED_CWDS") + candidates = ( + [Path(item).expanduser() for item in raw.split(os.pathsep) if item] + if raw + else [Path.cwd(), Path(tempfile.gettempdir())] + ) + roots: list[Path] = [] + for candidate in candidates: + try: + resolved = candidate.resolve() + except OSError: + continue + if resolved.is_dir(): + roots.append(resolved) + return roots + + +def latest_user_message(run_input: RunAgentInput) -> tuple[str, list[dict[str, Any]]] | None: + """Return the newest user message as A2A wire parts.""" + for message in reversed(run_input.messages): + if getattr(message, "role", None) != "user": + continue + message_id = str(message.id) + content = message.content + if isinstance(content, str): + return message_id, [{"text": content}] + if not isinstance(content, list): + raise AguiError("INVALID_INPUT", "The user message content is invalid.") + parts: list[dict[str, Any]] = [] + total_image_bytes = 0 + for raw_part in content: + part: Any = raw_part + part_type = getattr(part, "type", None) + if part_type == "text": + parts.append({"text": str(part.text)}) + continue + if part_type != "image": + raise AguiError("INVALID_INPUT", "Only text and inline data images are supported.") + source = part.source + if getattr(source, "type", None) != "data": + raise AguiError("INVALID_INPUT", "Remote media URLs are not supported.") + mime_type = str(source.mime_type).lower() + if mime_type not in SUPPORTED_IMAGE_MIME_TYPES: + raise AguiError("INVALID_INPUT", "The image media type is not supported.") + try: + raw = base64.b64decode(source.value.encode("ascii"), validate=True) + except (UnicodeEncodeError, ValueError, binascii.Error) as exc: + raise AguiError("INVALID_INPUT", "The image data is not valid base64.") from exc + if len(raw) > MAX_IMAGE_BYTES: + raise AguiError("INVALID_INPUT", "An image exceeds the maximum size.") + total_image_bytes += len(raw) + if total_image_bytes > MAX_TOTAL_IMAGE_BYTES: + raise AguiError("INVALID_INPUT", "The total image content exceeds the maximum size.") + try: + resized = maybe_resize_and_downsample(raw) + except Exception as exc: + raise AguiError("INVALID_INPUT", "The image data is invalid.") from exc + parts.append( + { + "data": { + "filename": f"agui-image-{len(parts) + 1}", + "bytes": base64.b64encode(resized.data).decode("ascii"), + }, + "mediaType": resized.media_type, + } + ) + return message_id, parts + return None + + +def _is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True diff --git a/src/iac_code/agui/process.py b/src/iac_code/agui/process.py new file mode 100644 index 00000000..420931d0 --- /dev/null +++ b/src/iac_code/agui/process.py @@ -0,0 +1,94 @@ +"""Lifecycle management for the local A2A execution-kernel subprocess.""" + +from __future__ import annotations + +import os +import secrets +import socket +import subprocess +import sys +import time +from dataclasses import dataclass + +import httpx + +from iac_code.i18n import _ + + +@dataclass +class LocalA2AProcess: + host: str = "127.0.0.1" + startup_timeout: float = 20.0 + + def __post_init__(self) -> None: + self.port = _available_port(self.host) + self.token = secrets.token_urlsafe(32) + self.url = f"http://{self.host}:{self.port}/" + self._process: subprocess.Popen[bytes] | None = None + + def start(self) -> None: + if self._process is not None: + raise RuntimeError(_("The local A2A process is already started.")) + env = os.environ.copy() + env["IACCODE_A2A_HTTP_TOKEN"] = self.token + agui_roots = env.get("IAC_CODE_AGUI_ALLOWED_CWDS") + if agui_roots and not env.get("IACCODE_A2A_ALLOWED_CWDS"): + env["IACCODE_A2A_ALLOWED_CWDS"] = agui_roots + command = [ + sys.executable, + "-c", + "from iac_code.cli.main import app; app()", + "a2a", + "--host", + self.host, + "--port", + str(self.port), + "--thinking-exposure", + "all", + ] + self._process = subprocess.Popen(command, env=env) + self._wait_until_ready() + + def close(self) -> None: + process = self._process + self._process = None + if process is None or process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + def _wait_until_ready(self) -> None: + assert self._process is not None + deadline = time.monotonic() + self.startup_timeout + headers = {"Authorization": f"Bearer {self.token}"} + while time.monotonic() < deadline: + return_code = self._process.poll() + if return_code is not None: + self._process = None + raise RuntimeError(_("The local A2A process exited during startup (exit code {}).").format(return_code)) + try: + response = httpx.get(self.url + "health", headers=headers, timeout=0.5) + if response.status_code == 200: + return + except httpx.HTTPError: + pass + time.sleep(0.1) + self.close() + raise RuntimeError(_("The local A2A process did not become ready in time.")) + + def __enter__(self) -> LocalA2AProcess: + self.start() + return self + + def __exit__(self, *_: object) -> None: + self.close() + + +def _available_port(host: str) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return int(sock.getsockname()[1]) diff --git a/src/iac_code/agui/server.py b/src/iac_code/agui/server.py new file mode 100644 index 00000000..b74d7c83 --- /dev/null +++ b/src/iac_code/agui/server.py @@ -0,0 +1,84 @@ +"""Uvicorn entry point for the A2A-backed AG-UI adapter.""" + +from __future__ import annotations + +import contextlib +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from iac_code.a2a.client import A2AClient +from iac_code.a2a.transport import A2AAuthConfig +from iac_code.agui.process import LocalA2AProcess +from iac_code.i18n import _ + + +def run_server( + *, + host: str = "127.0.0.1", + port: int = 8000, + a2a_url: str | None = None, + a2a_token: str | None = None, + interrupt_ttl: int = 540, + state_dir: str | Path | None = None, + debug: bool = False, + auth_token: str | None = None, + idle_shutdown: float = 0, +) -> None: + import uvicorn + + from iac_code.agui.app import create_app + + with _a2a_endpoint(a2a_url=a2a_url, a2a_token=a2a_token) as endpoint: + client = A2AClient( + auth=A2AAuthConfig(bearer_token=endpoint[1]) if endpoint[1] else None, + timeout_seconds=3600, + ) + server: Any | None = None + + def request_shutdown() -> None: + assert server is not None + server.should_exit = True + + app = create_app( + a2a_url=endpoint[0], + a2a_client=client, + interrupt_ttl=interrupt_ttl, + state_dir=state_dir, + auth_token=auth_token, + idle_shutdown=idle_shutdown, + request_shutdown=request_shutdown, + ) + config = uvicorn.Config( + app, + host=host, + port=port, + log_level="debug" if debug else "info", + workers=1, + ) + server = uvicorn.Server(config) + server.run() + + +@contextlib.contextmanager +def _a2a_endpoint(*, a2a_url: str | None, a2a_token: str | None) -> Iterator[tuple[str, str | None]]: + configured_url = a2a_url or os.environ.get("IAC_CODE_AGUI_A2A_URL") + configured_token = a2a_token or os.environ.get("IAC_CODE_AGUI_A2A_TOKEN") + if configured_url: + yield _local_a2a_url(configured_url), configured_token + return + process: Any = LocalA2AProcess() + try: + process.start() + yield process.url, process.token + finally: + process.close() + + +def _local_a2a_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or parsed.hostname not in {"127.0.0.1", "localhost", "::1"}: + raise ValueError(_("The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL.")) + return value.rstrip("/") + "/" diff --git a/src/iac_code/agui/state.py b/src/iac_code/agui/state.py new file mode 100644 index 00000000..c8665dfd --- /dev/null +++ b/src/iac_code/agui/state.py @@ -0,0 +1,131 @@ +"""Durable, execution-free state for the AG-UI to A2A adapter.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Protocol + +from iac_code.config import get_config_dir +from iac_code.utils.file_security import ensure_private_dir, ensure_private_file +from iac_code.utils.state_io import atomic_write_json, open_text_no_follow + +AGUI_STATE_SCHEMA_VERSION = 1 +_STATE_DIR_ENV = "IAC_CODE_AGUI_STATE_DIR" +_THREADS_DIR_NAME = "threads" +_ENCODED_THREAD_ID_PREFIX = "aguiid~" +_HASHED_THREAD_ID_PREFIX = "aguihash~" +_MAX_ATOMIC_FILE_STEM_LENGTH = 200 +_SAFE_THREAD_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") +_WINDOWS_RESERVED_BASENAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +} + + +class AguiStateStoreError(RuntimeError): + """Durable AG-UI thread state could not be read or committed.""" + + +class AguiStateStore(Protocol): + """Per-thread persistence boundary used by :class:`AguiA2AAdapter`.""" + + def load_thread(self, thread_id: str) -> dict[str, Any] | None: ... + + def save_thread(self, thread_id: str, state: Mapping[str, Any]) -> None: ... + + +class FileAguiThreadStateStore: + """Owner-private, atomically replaced JSON state split by AG-UI thread.""" + + def __init__(self, state_dir: str | Path | None = None) -> None: + self.state_dir = resolve_agui_state_dir(state_dir) + self.threads_dir = self.state_dir / _THREADS_DIR_NAME + + def path_for_thread(self, thread_id: str) -> Path: + return self.threads_dir / f"{_thread_file_stem(thread_id)}.json" + + def load_thread(self, thread_id: str) -> dict[str, Any] | None: + path = self.path_for_thread(thread_id) + if not path.exists(): + return None + try: + ensure_private_file(path) + with open_text_no_follow(path, "r") as handle: + value = json.load(handle) + except Exception as exc: + raise AguiStateStoreError("Unable to read the AG-UI thread state.") from exc + if ( + not isinstance(value, dict) + or value.get("schemaVersion") != AGUI_STATE_SCHEMA_VERSION + or value.get("threadId") != thread_id + ): + raise AguiStateStoreError("Unsupported or invalid AG-UI thread state schema.") + return value + + def save_thread(self, thread_id: str, state: Mapping[str, Any]) -> None: + document = dict(state) + if ( + document.get("schemaVersion") != AGUI_STATE_SCHEMA_VERSION + or document.get("threadId") != thread_id + ): + raise AguiStateStoreError("Refusing to save invalid AG-UI thread state.") + try: + ensure_private_dir(self.state_dir) + ensure_private_dir(self.threads_dir) + path = self.path_for_thread(thread_id) + atomic_write_json(path, document) + ensure_private_file(path) + except Exception as exc: + raise AguiStateStoreError("Unable to commit the AG-UI thread state.") from exc + + +def resolve_agui_state_dir(value: str | Path | None = None) -> Path: + """Resolve an explicit/env state root without coupling it to any request cwd.""" + + raw = str(value).strip() if value is not None else os.environ.get(_STATE_DIR_ENV, "").strip() + if raw: + return Path(os.path.expandvars(os.path.expanduser(raw))).resolve() + return get_config_dir() / "agui" + + +def _thread_file_stem(thread_id: str) -> str: + if not isinstance(thread_id, str) or not thread_id: + raise AguiStateStoreError("AG-UI thread id must be a non-empty string.") + basename = thread_id.rstrip(".").split(".", 1)[0].upper() + if ( + _SAFE_THREAD_ID_PATTERN.fullmatch(thread_id) is not None + and thread_id == thread_id.lower() + and thread_id not in {".", ".."} + and thread_id == thread_id.rstrip(".") + and basename not in _WINDOWS_RESERVED_BASENAMES + ): + return thread_id + # Lowercase hexadecimal is reversible and remains collision-free on the + # case-insensitive filesystems used by default on Windows and macOS. + encoded = thread_id.encode("utf-8").hex() + encoded_stem = f"{_ENCODED_THREAD_ID_PREFIX}{encoded}" + if len(encoded_stem) <= _MAX_ATOMIC_FILE_STEM_LENGTH: + return encoded_stem + # The original id remains in the JSON document and is validated when read. + # A fixed-length lowercase key avoids filesystem component limits while + # retaining case-insensitive safety for unusually long client-provided ids. + digest = hashlib.sha256(thread_id.encode("utf-8")).hexdigest() + return f"{_HASHED_THREAD_ID_PREFIX}{digest}" + + +__all__ = [ + "AGUI_STATE_SCHEMA_VERSION", + "AguiStateStore", + "AguiStateStoreError", + "FileAguiThreadStateStore", + "resolve_agui_state_dir", +] diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index 742bf006..1cad51e1 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -58,6 +58,10 @@ def _a2a_server_missing_dependencies_message() -> str: return _("A2A server dependencies are missing. Install with: pip install 'iac-code[a2a]'") +def _agui_server_missing_dependencies_message() -> str: + return _("AG-UI adapter dependencies are missing. Install with: pip install 'iac-code[agui]'") + + def _piped_repl_requires_tty_message(piped_input: str) -> str: if piped_input.strip().startswith("/mcp"): return _( @@ -609,6 +613,20 @@ def _load_a2a_config(path: str) -> dict[str, Any]: return _normalize_a2a_config_mapping(data) +def _load_agui_config(path: str) -> dict[str, Any]: + import yaml + + try: + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + except Exception as exc: + raise typer.BadParameter(_("Unable to load the AG-UI config file.")) from exc + if data is None: + return {} + if not isinstance(data, dict): + raise typer.BadParameter(_("AG-UI config file must contain a YAML mapping.")) + return _normalize_a2a_config_mapping(data) + + def _normalize_a2a_config_mapping(data: dict[Any, Any]) -> dict[str, Any]: return {str(key).replace("-", "_"): _normalize_a2a_config_value(value) for key, value in data.items()} @@ -734,6 +752,105 @@ def _current_logical_cwd() -> str: return str(physical_cwd) +@app.command(help=_("Run the AG-UI adapter backed by a local iac-code A2A process.")) +def agui( + ctx: typer.Context, + config_path: str = typer.Option("", "--config", help=_("YAML config file for AG-UI adapter options")), + host: str = typer.Option("127.0.0.1", envvar="IAC_CODE_AGUI_HOST", help=_("HTTP server host")), + port: int = typer.Option(8000, envvar="IAC_CODE_AGUI_PORT", help=_("HTTP server port")), + a2a_url: str = typer.Option( + "", + "--a2a-url", + envvar="IAC_CODE_AGUI_A2A_URL", + help=_("Existing local A2A URL; omitted starts a managed A2A child process"), + ), + debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")), + log_stdout: bool = typer.Option( + False, + "--log-stdout/--no-log-stdout", + help=_("Mirror adapter logs to stdout"), + ), + interrupt_ttl: int = typer.Option( + 540, + "--interrupt-ttl", + envvar="IAC_CODE_AGUI_INTERRUPT_TTL", + help=_("Seconds an AG-UI interrupt remains resumable"), + ), + state_dir: str = typer.Option( + "", + "--state-dir", + envvar="IAC_CODE_AGUI_STATE_DIR", + help=_("Durable AG-UI adapter state directory"), + ), + idle_shutdown: float = typer.Option( + 0, + "--idle-shutdown", + help=_("Exit after this many idle seconds; zero disables idle shutdown"), + ), +) -> None: + """Run AG-UI as a protocol adapter; all execution remains in A2A.""" + try: + config = _load_agui_config(config_path) if config_path else {} + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + host = _a2a_config_value(ctx, config, "host", host) + port = _a2a_config_value(ctx, config, "port", port) + a2a_url = _a2a_config_value(ctx, config, "a2a_url", a2a_url) + debug = _a2a_config_value(ctx, config, "debug", debug) + log_stdout = _a2a_config_value(ctx, config, "log_stdout", log_stdout) + interrupt_ttl = _a2a_config_value(ctx, config, "interrupt_ttl", interrupt_ttl) + state_dir = _a2a_config_value(ctx, config, "state_dir", state_dir) + idle_shutdown = _a2a_config_value(ctx, config, "idle_shutdown", idle_shutdown) + if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535: + typer.echo(_("--port must be between 1 and 65535."), err=True) + raise typer.Exit(1) + if not isinstance(interrupt_ttl, int) or isinstance(interrupt_ttl, bool) or interrupt_ttl <= 0: + typer.echo(_("--interrupt-ttl must be a positive integer."), err=True) + raise typer.Exit(1) + if state_dir and not isinstance(state_dir, str): + typer.echo(_("--state-dir must be a string."), err=True) + raise typer.Exit(1) + if not isinstance(idle_shutdown, (int, float)) or isinstance(idle_shutdown, bool) or idle_shutdown < 0: + typer.echo(_("--idle-shutdown must be a non-negative number."), err=True) + raise typer.Exit(1) + if a2a_url and not isinstance(a2a_url, str): + typer.echo(_("--a2a-url must be a string."), err=True) + raise typer.Exit(1) + auth_token = os.environ.get("IAC_CODE_AGUI_AUTH_TOKEN") or config.get("auth_token") + a2a_token = os.environ.get("IAC_CODE_AGUI_A2A_TOKEN") or config.get("a2a_token") + if auth_token is not None and not isinstance(auth_token, str): + typer.echo(_("auth_token must be a string."), err=True) + raise typer.Exit(1) + if a2a_token is not None and not isinstance(a2a_token, str): + typer.echo(_("a2a_token must be a string."), err=True) + raise typer.Exit(1) + setup_logging(session_id="agui-adapter", debug=bool(debug), stdout=bool(log_stdout)) + try: + from iac_code.agui.server import run_server + except ImportError as exc: + typer.echo(_agui_server_missing_dependencies_message(), err=True) + raise typer.Exit(1) from exc + try: + run_server( + host=str(host), + port=port, + a2a_url=a2a_url or None, + a2a_token=a2a_token, + interrupt_ttl=interrupt_ttl, + state_dir=state_dir or None, + debug=bool(debug), + auth_token=auth_token, + idle_shutdown=float(idle_shutdown), + ) + except ImportError as exc: + typer.echo(_agui_server_missing_dependencies_message(), err=True) + raise typer.Exit(1) from exc + except (RuntimeError, ValueError) as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + @app.command(help=_("Run iac-code as an A2A 1.0 server.")) def a2a( ctx: typer.Context, diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 75352e41..5584ff1e 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -228,7 +228,7 @@ msgstr "Diese Operation für die angeforderte Infrastrukturaufgabe ausführen." msgid "the current task workspace or cloud account" msgstr "der aktuelle Aufgaben-Arbeitsbereich oder das Cloud-Konto" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "Berechtigung erforderlich" @@ -340,7 +340,7 @@ msgstr "A2A-Pipeline-Zustand nicht gefunden" msgid "A2A task/context mismatch" msgstr "A2A-Aufgabe und Kontext passen nicht zusammen" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "Eingabe erforderlich" @@ -731,6 +731,268 @@ msgstr "" "Zur Planung der Implementierungsstrategie, Architekturprüfung oder " "Lösungsentwicklung. Nur Lesen, keine Ausführung." +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "Der Content-Type muss application/json sein." + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "Der AG-UI-Anfrageinhalt ist zu groß." + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "Der Anfrageinhalt ist kein gültiges JSON." + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput muss ein JSON-Objekt sein." + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "Die AG-UI-RunAgentInput-Struktur ist ungültig." + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "Die weitergeleiteten iac-code-Eigenschaften sind ungültig." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "Der Inhalt der Abbruchanfrage ist zu groß." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "Der Inhalt der Abbruchanfrage ist kein gültiges JSON." + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "Die Abbruchanfrage muss ein JSON-Objekt sein." + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "threadId und rosInvocationId sind erforderlich." + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "Die Ausführung wurde nicht gefunden." + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "Ein gültiges Bearer-Token ist erforderlich." + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "Für eine neue Ausführung ist eine Benutzernachricht erforderlich." + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "Eine aufgelöste Unterbrechung erfordert Antwortdaten." + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "Ein Bild überschreitet die maximale Größe." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "Eine Unterbrechung gehört zu einem anderen A2A-Kontext." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "Eine Unterbrechung gehört zu einer anderen A2A-Aufgabe." + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "Vom Client bereitgestellte Tools werden nicht unterstützt." + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "Es werden nur Text und eingebettete Datenbilder unterstützt." + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "URLs zu entfernten Medien werden nicht unterstützt." + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "Die A2A-Kontextidentität hat sich unerwartet geändert." + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "Die A2A-Ausführung ist fehlgeschlagen." + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "Die A2A-Unterbrechungsantwort wurde nicht akzeptiert." + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "Die A2A-Berechtigungsantwort wurde nicht akzeptiert." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "Die A2A-Aufgabenidentität hat sich unerwartet geändert." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "Die A2A-Aufgabenidentität stimmt nicht mit der unterbrochenen Ausführung überein." + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "Die fortzusetzende A2A-Aufgabe ist nicht verfügbar." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "Der Zustand des AG-UI-Adapters ist nicht verfügbar." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "Die AG-UI-Ausführungs-ID wurde bereits verwendet." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "Der AG-UI-Thread hat bereits eine aktive Ausführung." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "Der AG-UI-Thread ist bereits an einen anderen Arbeitsbereich oder Aufrufer gebunden." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "Der AG-UI-Thread wartet auf Unterbrechungsantworten." + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "Die akzeptierte Unterbrechungsantwort konnte nicht gespeichert werden." + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "Die Ausführungszuordnung konnte nicht gespeichert werden." + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "Die Zuordnung von Ausführung und Sitzung konnte nicht gespeichert werden." + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "Die fortzusetzende Ausführung ist nicht mehr verfügbar." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "Die Ausführung wurde durch die Unterbrechungsantwort abgebrochen." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "Die Ausführung wurde abgebrochen." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "Die Identität der iac-code-Sitzung hat sich unerwartet geändert." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "Die fortzusetzende iac-code-Sitzung ist nicht verfügbar." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "Der iac-code-Arbeitsbereich kann nicht erstellt werden." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "Der iac-code-Arbeitsbereich ist ungültig." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "Der iac-code-Arbeitsbereich ist kein Verzeichnis." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "Der iac-code-Arbeitsbereich liegt außerhalb der zulässigen Stammverzeichnisse." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "Der iac-code-Arbeitsbereich muss ein absoluter Pfad sein." + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "Die Bilddaten sind ungültig." + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "Die Bilddaten sind kein gültiges base64." + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "Der Medientyp des Bildes wird nicht unterstützt." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "Die Unterbrechungsantwort enthält keine Antwort." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "Die Unterbrechungsantwort wurde bereits angewendet." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "Die Antwortdaten der Unterbrechung sind ungültig." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "Die unterbrochene Ausführung ist abgelaufen." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "Der Zustand der unterbrochenen Ausführung konnte nicht gespeichert werden." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "Der lokale A2A-Ausführungsdienst ist nicht verfügbar." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "Der lokale A2A-Ausführungsdienst hat die Unterbrechungsantwort abgelehnt." + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "Die Fortsetzung enthält doppelte Unterbrechungs-IDs." + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "Beim Fortsetzen muss jede ausstehende Unterbrechung genau einmal aufgelöst werden." + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "Die Fortsetzung verweist auf eine unbekannte Unterbrechung." + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "Die Fortsetzungsanfrage stimmt nicht mit der unterbrochenen Ausführung überein." + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "Der gesamte Bildinhalt überschreitet die maximale Größe." + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "Der Inhalt der Benutzernachricht ist ungültig." + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "Der lokale A2A-Prozess wurde bereits gestartet." + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "Der lokale A2A-Prozess wurde während des Starts beendet (Exit-Code {})." + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "Der lokale A2A-Prozess wurde nicht rechtzeitig bereit." + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "Der AG-UI-Adapter darf nur eine A2A-HTTP(S)-URL auf einer Loopback-Adresse verwenden." + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -867,6 +1129,14 @@ msgstr "" "A2A-Server-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" "code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "" +"Abhängigkeiten des AG-UI-Adapters fehlen. Installation mit: pip install " +"'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -1038,6 +1308,75 @@ msgstr "iac-code als ACP-Server ausführen." msgid "Transport type: stdio or http" msgstr "Transporttyp: stdio oder http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "Die AG-UI-Konfigurationsdatei kann nicht geladen werden." + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "Die AG-UI-Konfigurationsdatei muss ein YAML-Mapping enthalten." + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "Den AG-UI-Adapter mit einem lokalen iac-code-A2A-Prozess ausführen." + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "YAML-Konfigurationsdatei für Optionen des AG-UI-Adapters" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "" +"Vorhandene lokale A2A-URL; ohne Angabe wird ein verwalteter A2A-" +"Kindprozess gestartet" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "Adapterprotokolle nach stdout spiegeln" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "Sekunden, in denen eine AG-UI-Unterbrechung fortgesetzt werden kann" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "Verzeichnis für den persistenten Zustand des AG-UI-Adapters" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "" +"Nach so vielen Leerlaufsekunden beenden; null deaktiviert das Beenden bei" +" Leerlauf" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port muss zwischen 1 und 65535 liegen." + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl muss eine positive Ganzzahl sein." + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir muss eine Zeichenfolge sein." + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown muss eine nicht negative Zahl sein." + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url muss eine Zeichenfolge sein." + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token muss eine Zeichenfolge sein." + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token muss eine Zeichenfolge sein." + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "iac-code als A2A 1.0-Server ausführen." diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 274d90a8..be8feacb 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -227,7 +227,7 @@ msgstr "Ejecutar esta operación para la tarea de infraestructura solicitada." msgid "the current task workspace or cloud account" msgstr "el área de trabajo de la tarea actual o la cuenta de la nube" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "Se requiere permiso" @@ -339,7 +339,7 @@ msgstr "No se encontró el estado del pipeline A2A" msgid "A2A task/context mismatch" msgstr "La tarea y el contexto A2A no coinciden" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "Entrada requerida" @@ -731,6 +731,268 @@ msgstr "" "Úsalo para planificar la estrategia de implementación, revisar la " "arquitectura o diseñar soluciones. Solo lectura, sin ejecución." +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "El tipo de contenido debe ser application/json." + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "El cuerpo de la solicitud AG-UI es demasiado grande." + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "El cuerpo de la solicitud no contiene JSON válido." + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput debe ser un objeto JSON." + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "El contenedor RunAgentInput de AG-UI no es válido." + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "Las propiedades reenviadas de iac-code no son válidas." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "El cuerpo de la solicitud de cancelación es demasiado grande." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "El cuerpo de la solicitud de cancelación no contiene JSON válido." + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "La solicitud de cancelación debe ser un objeto JSON." + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "Se requieren threadId y rosInvocationId." + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "No se encontró la ejecución." + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "Se requiere un token Bearer válido." + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "Una nueva ejecución requiere un mensaje del usuario." + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "Una interrupción resuelta requiere datos de respuesta." + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "Una imagen supera el tamaño máximo." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "Una interrupción pertenece a otro contexto A2A." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "Una interrupción pertenece a otra tarea A2A." + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "No se admiten herramientas proporcionadas por el cliente." + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "Solo se admiten texto e imágenes de datos en línea." + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "No se admiten URL de contenido multimedia remoto." + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "La identidad del contexto A2A cambió inesperadamente." + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "La ejecución A2A falló." + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "No se aceptó la respuesta a la interrupción A2A." + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "No se aceptó la respuesta de permiso A2A." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "La identidad de la tarea A2A cambió inesperadamente." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "La identidad de la tarea A2A no coincide con la ejecución interrumpida." + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "La tarea A2A que se desea reanudar no está disponible." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "El estado del adaptador AG-UI no está disponible." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "El identificador de ejecución AG-UI ya se ha utilizado." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "El hilo AG-UI ya tiene una ejecución activa." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "El hilo AG-UI ya está vinculado a otro espacio de trabajo o llamador." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "El hilo AG-UI está esperando respuestas a interrupciones." + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "No se pudo guardar la respuesta de interrupción aceptada." + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "No se pudo guardar la asignación de ejecución." + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "No se pudo guardar la asignación entre ejecución y sesión." + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "La ejecución que se desea reanudar ya no está disponible." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "La ejecución fue cancelada por la respuesta a la interrupción." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "La ejecución fue cancelada." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "La identidad de la sesión de iac-code cambió inesperadamente." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "La sesión de iac-code que se desea reanudar no está disponible." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "No se puede crear el espacio de trabajo de iac-code." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "El espacio de trabajo de iac-code no es válido." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "El espacio de trabajo de iac-code no es un directorio." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "El espacio de trabajo de iac-code está fuera de las raíces permitidas." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "El espacio de trabajo de iac-code debe ser una ruta absoluta." + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "Los datos de la imagen no son válidos." + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "Los datos de la imagen no son base64 válido." + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "El tipo de medio de la imagen no es compatible." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "La respuesta a la interrupción no contiene una contestación." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "La respuesta a la interrupción ya se ha aplicado." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "Los datos de la respuesta a la interrupción no son válidos." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "La ejecución interrumpida ha caducado." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "No se pudo guardar el estado de la ejecución interrumpida." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "El servicio de ejecución A2A local no está disponible." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "El servicio de ejecución A2A local rechazó la respuesta a la interrupción." + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "La reanudación contiene identificadores de interrupción duplicados." + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "La reanudación debe resolver cada interrupción pendiente exactamente una vez." + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "La reanudación hace referencia a una interrupción desconocida." + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "La solicitud de reanudación no coincide con la ejecución interrumpida." + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "El contenido total de las imágenes supera el tamaño máximo." + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "El contenido del mensaje del usuario no es válido." + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "El proceso A2A local ya está iniciado." + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "El proceso A2A local terminó durante el inicio (código de salida {})." + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "El proceso A2A local no estuvo listo a tiempo." + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "El adaptador AG-UI solo puede conectarse a una URL HTTP(S) A2A de bucle local." + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -864,6 +1126,14 @@ msgstr "" "Faltan las dependencias del servidor A2A. Instálalas con: pip install " "'iac-code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "" +"Faltan las dependencias del adaptador AG-UI. Instálelas con: pip install " +"'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -1028,6 +1298,77 @@ msgstr "Ejecutar iac-code como servidor ACP." msgid "Transport type: stdio or http" msgstr "Tipo de transporte: stdio o http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "No se puede cargar el archivo de configuración de AG-UI." + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "El archivo de configuración de AG-UI debe contener un mapa YAML." + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "" +"Ejecutar el adaptador AG-UI respaldado por un proceso A2A local de iac-" +"code." + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "Archivo de configuración YAML para las opciones del adaptador AG-UI" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "" +"URL A2A local existente; si se omite, inicia un proceso secundario A2A " +"administrado" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "Duplicar los registros del adaptador en stdout" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "Segundos durante los que una interrupción AG-UI puede reanudarse" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "Directorio de estado persistente del adaptador AG-UI" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "" +"Salir tras estos segundos de inactividad; cero desactiva el cierre por " +"inactividad" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port debe estar entre 1 y 65535." + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl debe ser un entero positivo." + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir debe ser una cadena." + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown debe ser un número no negativo." + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url debe ser una cadena." + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token debe ser una cadena." + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token debe ser una cadena." + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "Ejecuta iac-code como un servidor A2A 1.0." diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index d19c6f41..4fc68842 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -221,7 +221,7 @@ msgstr "Exécuter cette opération pour la tâche d'infrastructure demandée." msgid "the current task workspace or cloud account" msgstr "l'espace de travail de la tâche actuelle ou le compte cloud" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "Autorisation requise" @@ -333,7 +333,7 @@ msgstr "État du pipeline A2A introuvable" msgid "A2A task/context mismatch" msgstr "La tâche et le contexte A2A ne correspondent pas" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "Saisie requise" @@ -721,6 +721,268 @@ msgstr "" "À utiliser pour planifier la stratégie d’implémentation, revoir " "l’architecture ou concevoir des solutions. Lecture seule, sans exécution." +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "Le type de contenu doit être application/json." + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "Le corps de la requête AG-UI est trop volumineux." + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "Le corps de la requête n’est pas un JSON valide." + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput doit être un objet JSON." + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "L’enveloppe AG-UI RunAgentInput n’est pas valide." + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "Les propriétés transférées d’iac-code ne sont pas valides." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "Le corps de la demande d’annulation est trop volumineux." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "Le corps de la demande d’annulation n’est pas un JSON valide." + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "La demande d’annulation doit être un objet JSON." + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "threadId et rosInvocationId sont requis." + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "L’exécution est introuvable." + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "Un jeton Bearer valide est requis." + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "Une nouvelle exécution nécessite un message utilisateur." + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "Une interruption résolue nécessite des données de réponse." + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "Une image dépasse la taille maximale." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "Une interruption appartient à un autre contexte A2A." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "Une interruption appartient à une autre tâche A2A." + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "Les outils fournis par le client ne sont pas pris en charge." + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "Seuls le texte et les images de données intégrées sont pris en charge." + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "Les URL de médias distants ne sont pas prises en charge." + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "L’identité du contexte A2A a changé de manière inattendue." + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "L’exécution A2A a échoué." + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "La réponse à l’interruption A2A n’a pas été acceptée." + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "La réponse d’autorisation A2A n’a pas été acceptée." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "L’identité de la tâche A2A a changé de manière inattendue." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "L’identité de la tâche A2A ne correspond pas à l’exécution interrompue." + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "La tâche A2A à reprendre n’est pas disponible." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "L’état de l’adaptateur AG-UI n’est pas disponible." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "L’identifiant d’exécution AG-UI a déjà été utilisé." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "Le thread AG-UI possède déjà une exécution active." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "Le thread AG-UI est déjà associé à un autre espace de travail ou appelant." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "Le thread AG-UI attend des réponses aux interruptions." + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "La réponse d’interruption acceptée n’a pas pu être enregistrée." + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "L’association d’exécution n’a pas pu être enregistrée." + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "L’association entre l’exécution et la session n’a pas pu être enregistrée." + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "L’exécution à reprendre n’est plus disponible." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "L’exécution a été annulée par la réponse à l’interruption." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "L’exécution a été annulée." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "L’identité de la session iac-code a changé de manière inattendue." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "La session iac-code à reprendre n’est pas disponible." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "L’espace de travail iac-code ne peut pas être créé." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "L’espace de travail iac-code n’est pas valide." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "L’espace de travail iac-code n’est pas un répertoire." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "L’espace de travail iac-code se trouve hors des racines autorisées." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "L’espace de travail iac-code doit être un chemin absolu." + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "Les données de l’image ne sont pas valides." + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "Les données de l’image ne sont pas un base64 valide." + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "Le type de média de l’image n’est pas pris en charge." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "La réponse à l’interruption ne contient aucune réponse." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "La réponse à l’interruption a déjà été appliquée." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "Les données de réponse à l’interruption ne sont pas valides." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "L’exécution interrompue a expiré." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "L’état de l’exécution interrompue n’a pas pu être enregistré." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "Le service d’exécution A2A local n’est pas disponible." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "Le service d’exécution A2A local a rejeté la réponse à l’interruption." + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "La reprise contient des identifiants d’interruption en double." + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "La reprise doit résoudre chaque interruption en attente exactement une fois." + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "La reprise fait référence à une interruption inconnue." + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "La demande de reprise ne correspond pas à l’exécution interrompue." + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "Le contenu total des images dépasse la taille maximale." + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "Le contenu du message utilisateur n’est pas valide." + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "Le processus A2A local est déjà démarré." + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "Le processus A2A local s’est arrêté au démarrage (code de sortie {})." + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "Le processus A2A local n’a pas été prêt à temps." + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "L’adaptateur AG-UI ne peut se connecter qu’à une URL HTTP(S) A2A en boucle locale." + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -857,6 +1119,14 @@ msgstr "" "Les dépendances du serveur A2A sont manquantes. Installez-les avec : pip " "install 'iac-code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "" +"Les dépendances de l’adaptateur AG-UI sont manquantes. Installez-les avec" +" : pip install 'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -1023,6 +1293,75 @@ msgstr "Exécuter iac-code comme serveur ACP." msgid "Transport type: stdio or http" msgstr "Type de transport : stdio ou http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "Impossible de charger le fichier de configuration AG-UI." + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "Le fichier de configuration AG-UI doit contenir un mappage YAML." + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "Exécuter l’adaptateur AG-UI adossé à un processus A2A iac-code local." + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "Fichier de configuration YAML pour les options de l’adaptateur AG-UI" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "" +"URL A2A locale existante ; si elle est omise, un processus enfant A2A " +"géré est démarré" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "Dupliquer les journaux de l’adaptateur vers stdout" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "Durée en secondes pendant laquelle une interruption AG-UI reste reprenable" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "Répertoire d’état persistant de l’adaptateur AG-UI" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "" +"Quitter après ce nombre de secondes d’inactivité ; zéro désactive cet " +"arrêt" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port doit être compris entre 1 et 65535." + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl doit être un entier positif." + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir doit être une chaîne." + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown doit être un nombre positif ou nul." + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url doit être une chaîne." + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token doit être une chaîne." + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token doit être une chaîne." + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "Exécute iac-code en tant que serveur A2A 1.0." diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 15f4f0c8..dd3bb41e 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -202,7 +202,7 @@ msgstr "依頼されたインフラタスクのためにこの操作を実行し msgid "the current task workspace or cloud account" msgstr "現在のタスクのワークスペースまたはクラウドアカウント" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "権限の確認が必要" @@ -312,7 +312,7 @@ msgstr "A2A パイプライン状態が見つかりません" msgid "A2A task/context mismatch" msgstr "A2A タスクとコンテキストが一致しません" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "入力が必要" @@ -687,6 +687,268 @@ msgid "" "solutions. Read-only, no execution." msgstr "実装戦略の計画、アーキテクチャのレビュー、または解決策の設計に使用します。読み取り専用で、実行はしません。" +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "Content-Type は application/json である必要があります。" + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "AG-UI 要求の本文が大きすぎます。" + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "要求の本文は有効な JSON ではありません。" + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput は JSON オブジェクトである必要があります。" + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "AG-UI RunAgentInput エンベロープが無効です。" + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "iac-code の転送プロパティが無効です。" + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "キャンセル要求の本文が大きすぎます。" + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "キャンセル要求の本文は有効な JSON ではありません。" + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "キャンセル要求は JSON オブジェクトである必要があります。" + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "threadId と rosInvocationId が必要です。" + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "実行が見つかりません。" + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "有効な Bearer Token が必要です。" + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "新しい実行にはユーザーメッセージが必要です。" + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "解決済みの割り込みには応答ペイロードが必要です。" + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "画像が最大サイズを超えています。" + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "割り込みは別の A2A コンテキストに属しています。" + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "割り込みは別の A2A タスクに属しています。" + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "クライアント提供のツールはサポートされていません。" + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "テキストとインラインデータ画像のみがサポートされています。" + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "リモートメディア URL はサポートされていません。" + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "A2A コンテキスト ID が予期せず変更されました。" + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "A2A の実行に失敗しました。" + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "A2A 割り込み応答は受け入れられませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "A2A 権限応答は受け入れられませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "A2A タスク ID が予期せず変更されました。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "A2A タスク ID が中断された実行と一致しません。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "再開対象の A2A タスクは利用できません。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "AG-UI アダプターの状態は利用できません。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "AG-UI run ID はすでに使用されています。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "AG-UI thread にはすでに実行中の run があります。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "AG-UI thread はすでに別のワークスペースまたは呼び出し元に関連付けられています。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "AG-UI thread は割り込み応答を待機しています。" + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "受け入れ済みの割り込み応答を保存できませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "実行の対応付けを保存できませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "実行とセッションの対応を保存できませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "再開対象の実行は使用できなくなりました。" + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "割り込み応答によって実行がキャンセルされました。" + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "実行はキャンセルされました。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "iac-code セッション ID が予期せず変更されました。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "再開対象の iac-code セッションは利用できません。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "iac-code ワークスペースを作成できません。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "iac-code ワークスペースが無効です。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "iac-code ワークスペースはディレクトリではありません。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "iac-code ワークスペースは許可されたルートの外にあります。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "iac-code ワークスペースは絶対パスである必要があります。" + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "画像データが無効です。" + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "画像データは有効な base64 ではありません。" + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "画像のメディアタイプはサポートされていません。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "割り込み応答に回答が含まれていません。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "割り込み応答はすでに適用されています。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "割り込み応答のペイロードが無効です。" + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "中断された実行は期限切れです。" + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "中断された実行状態を保存できませんでした。" + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "ローカル A2A 実行サービスは利用できません。" + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "ローカル A2A 実行サービスが割り込み応答を拒否しました。" + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "再開に重複した割り込み ID が含まれています。" + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "再開では、保留中の各割り込みを正確に 1 回ずつ解決する必要があります。" + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "再開が不明な割り込みを参照しています。" + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "再開要求が中断された実行と一致しません。" + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "画像コンテンツの合計が最大サイズを超えています。" + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "ユーザーメッセージの内容が無効です。" + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "ローカル A2A プロセスはすでに起動しています。" + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "ローカル A2A プロセスは起動中に終了しました(終了コード {})。" + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "ローカル A2A プロセスは時間内に準備できませんでした。" + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "AG-UI アダプターはループバック上の A2A HTTP(S) URL にのみ接続できます。" + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -815,6 +1077,12 @@ msgid "" "code[a2a]'" msgstr "A2A サーバーの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "AG-UI アダプターの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -970,6 +1238,71 @@ msgstr "iac-code を ACP サーバーとして実行します。" msgid "Transport type: stdio or http" msgstr "トランスポートの種類:stdio または http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "AG-UI 設定ファイルを読み込めません。" + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "AG-UI 設定ファイルには YAML マッピングが必要です。" + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "ローカルの iac-code A2A プロセスを実行基盤とする AG-UI アダプターを起動します。" + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "AG-UI アダプターオプション用の YAML 設定ファイル" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "既存のローカル A2A URL。省略すると管理対象の A2A 子プロセスを起動します" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "アダプターのログを標準出力にも出力します" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "AG-UI 割り込みを再開可能な状態で保持する秒数" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "AG-UI アダプターの永続状態ディレクトリ" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "指定秒数アイドル状態が続くと終了します。0 でアイドル終了を無効にします" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port は 1 から 65535 の範囲で指定してください。" + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl は正の整数で指定してください。" + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir は文字列で指定してください。" + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown は 0 以上の数値で指定してください。" + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url は文字列で指定してください。" + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token は文字列で指定してください。" + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token は文字列で指定してください。" + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "iac-code を A2A 1.0 サーバーとして実行します。" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 951a8143..41f5123f 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -224,7 +224,7 @@ msgstr "Executar esta operação para a tarefa de infraestrutura solicitada." msgid "the current task workspace or cloud account" msgstr "o espaço de trabalho da tarefa atual ou a conta de nuvem" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "Permissão necessária" @@ -336,7 +336,7 @@ msgstr "Estado do pipeline A2A não encontrado" msgid "A2A task/context mismatch" msgstr "A tarefa e o contexto A2A não correspondem" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "Entrada necessária" @@ -723,6 +723,268 @@ msgstr "" "Use para planejar a estratégia de implementação, revisar a arquitetura ou" " projetar soluções. Somente leitura, sem execução." +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "O Content-Type deve ser application/json." + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "O corpo da solicitação AG-UI é muito grande." + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "O corpo da solicitação não é um JSON válido." + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput deve ser um objeto JSON." + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "O envelope RunAgentInput do AG-UI é inválido." + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "As propriedades encaminhadas do iac-code são inválidas." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "O corpo da solicitação de cancelamento é muito grande." + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "O corpo da solicitação de cancelamento não é um JSON válido." + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "A solicitação de cancelamento deve ser um objeto JSON." + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "threadId e rosInvocationId são obrigatórios." + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "A execução não foi encontrada." + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "É necessário um token Bearer válido." + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "Uma nova execução requer uma mensagem do usuário." + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "Uma interrupção resolvida requer dados de resposta." + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "Uma imagem excede o tamanho máximo." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "Uma interrupção pertence a outro contexto A2A." + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "Uma interrupção pertence a outra tarefa A2A." + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "Ferramentas fornecidas pelo cliente não são compatíveis." + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "Somente texto e imagens de dados embutidos são compatíveis." + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "URLs de mídia remota não são compatíveis." + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "A identidade do contexto A2A mudou inesperadamente." + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "A execução A2A falhou." + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "A resposta à interrupção A2A não foi aceita." + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "A resposta de permissão A2A não foi aceita." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "A identidade da tarefa A2A mudou inesperadamente." + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "A identidade da tarefa A2A não corresponde à execução interrompida." + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "A tarefa A2A a ser retomada não está disponível." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "O estado do adaptador AG-UI não está disponível." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "O ID de execução do AG-UI já foi usado." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "A thread do AG-UI já tem uma execução ativa." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "A thread do AG-UI já está vinculada a outro espaço de trabalho ou chamador." + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "A thread do AG-UI está aguardando respostas às interrupções." + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "Não foi possível salvar a resposta de interrupção aceita." + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "Não foi possível salvar o mapeamento da execução." + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "Não foi possível salvar o mapeamento entre execução e sessão." + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "A execução a ser retomada não está mais disponível." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "A execução foi cancelada pela resposta à interrupção." + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "A execução foi cancelada." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "A identidade da sessão do iac-code mudou inesperadamente." + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "A sessão do iac-code a ser retomada não está disponível." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "Não foi possível criar o espaço de trabalho do iac-code." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "O espaço de trabalho do iac-code é inválido." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "O espaço de trabalho do iac-code não é um diretório." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "O espaço de trabalho do iac-code está fora das raízes permitidas." + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "O espaço de trabalho do iac-code deve ser um caminho absoluto." + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "Os dados da imagem são inválidos." + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "Os dados da imagem não são um base64 válido." + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "O tipo de mídia da imagem não é compatível." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "A resposta à interrupção não contém uma resposta." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "A resposta à interrupção já foi aplicada." + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "Os dados da resposta à interrupção são inválidos." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "A execução interrompida expirou." + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "Não foi possível salvar o estado da execução interrompida." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "O serviço de execução A2A local não está disponível." + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "O serviço de execução A2A local rejeitou a resposta à interrupção." + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "A retomada contém IDs de interrupção duplicados." + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "A retomada deve resolver cada interrupção pendente exatamente uma vez." + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "A retomada faz referência a uma interrupção desconhecida." + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "A solicitação de retomada não corresponde à execução interrompida." + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "O conteúdo total das imagens excede o tamanho máximo." + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "O conteúdo da mensagem do usuário é inválido." + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "O processo A2A local já foi iniciado." + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "O processo A2A local foi encerrado durante a inicialização (código de saída {})." + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "O processo A2A local não ficou pronto a tempo." + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "O adaptador AG-UI só pode se conectar a uma URL HTTP(S) A2A de loopback." + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -857,6 +1119,14 @@ msgstr "" "As dependências do servidor A2A estão ausentes. Instale com: pip install " "'iac-code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "" +"As dependências do adaptador AG-UI estão ausentes. Instale com: pip " +"install 'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -1023,6 +1293,75 @@ msgstr "Executar o iac-code como servidor ACP." msgid "Transport type: stdio or http" msgstr "Tipo de transporte: stdio ou http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "Não foi possível carregar o arquivo de configuração do AG-UI." + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "O arquivo de configuração do AG-UI deve conter um mapeamento YAML." + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "Executar o adaptador AG-UI apoiado por um processo A2A local do iac-code." + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "Arquivo de configuração YAML para as opções do adaptador AG-UI" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "" +"URL A2A local existente; se omitida, inicia um processo filho A2A " +"gerenciado" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "Espelhar os logs do adaptador para stdout" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "Segundos durante os quais uma interrupção AG-UI pode ser retomada" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "Diretório de estado persistente do adaptador AG-UI" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "" +"Sair após estes segundos de inatividade; zero desativa o encerramento por" +" inatividade" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port deve estar entre 1 e 65535." + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl deve ser um inteiro positivo." + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir deve ser uma string." + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown deve ser um número não negativo." + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url deve ser uma string." + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token deve ser uma string." + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token deve ser uma string." + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "Executa o iac-code como servidor A2A 1.0." diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 152cd47f..f5cdd033 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -202,7 +202,7 @@ msgstr "为当前基础设施任务执行此操作。" msgid "the current task workspace or cloud account" msgstr "当前任务工作区或云账号" -#: src/iac_code/a2a/input_required.py +#: src/iac_code/a2a/input_required.py src/iac_code/agui/events.py msgid "Permission required" msgstr "需要权限确认" @@ -312,7 +312,7 @@ msgstr "未找到 A2A pipeline 状态" msgid "A2A task/context mismatch" msgstr "A2A 任务与上下文不匹配" -#: src/iac_code/a2a/pipeline_stream.py +#: src/iac_code/a2a/pipeline_stream.py src/iac_code/agui/events.py msgid "Input required" msgstr "需要输入" @@ -683,6 +683,268 @@ msgid "" "solutions. Read-only, no execution." msgstr "用于规划实现策略、审查架构或设计解决方案。只读,不执行命令。" +#: src/iac_code/agui/app.py +msgid "Content-Type must be application/json." +msgstr "Content-Type 必须为 application/json。" + +#: src/iac_code/agui/app.py +msgid "The AG-UI request body is too large." +msgstr "AG-UI 请求体过大。" + +#: src/iac_code/agui/app.py +msgid "The request body is not valid JSON." +msgstr "请求体不是有效的 JSON。" + +#: src/iac_code/agui/app.py +msgid "RunAgentInput must be a JSON object." +msgstr "RunAgentInput 必须是 JSON 对象。" + +#: src/iac_code/agui/app.py +msgid "Invalid AG-UI RunAgentInput envelope." +msgstr "无效的 AG-UI RunAgentInput 封装。" + +#: src/iac_code/agui/app.py src/iac_code/agui/errors.py +msgid "Invalid iac-code forwarded properties." +msgstr "无效的 iac-code 转发属性。" + +#: src/iac_code/agui/app.py +msgid "The cancel request body is too large." +msgstr "取消请求体过大。" + +#: src/iac_code/agui/app.py +msgid "The cancel request body is not valid JSON." +msgstr "取消请求体不是有效的 JSON。" + +#: src/iac_code/agui/app.py +msgid "The cancel request must be a JSON object." +msgstr "取消请求必须是 JSON 对象。" + +#: src/iac_code/agui/app.py +msgid "threadId and rosInvocationId are required." +msgstr "必须提供 threadId 和 rosInvocationId。" + +#: src/iac_code/agui/app.py +msgid "The execution was not found." +msgstr "未找到该执行。" + +#: src/iac_code/agui/app.py +msgid "A valid bearer token is required." +msgstr "需要有效的 Bearer Token。" + +#: src/iac_code/agui/errors.py +msgid "A new run requires a user message." +msgstr "新运行需要一条用户消息。" + +#: src/iac_code/agui/errors.py +msgid "A resolved interrupt requires a payload." +msgstr "已解决的中断必须包含响应内容。" + +#: src/iac_code/agui/errors.py +msgid "An image exceeds the maximum size." +msgstr "图片超过大小上限。" + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A context." +msgstr "中断属于另一个 A2A 上下文。" + +#: src/iac_code/agui/errors.py +msgid "An interrupt belongs to another A2A task." +msgstr "中断属于另一个 A2A 任务。" + +#: src/iac_code/agui/errors.py +msgid "Client-provided tools are not supported." +msgstr "不支持客户端提供的工具。" + +#: src/iac_code/agui/errors.py +msgid "Only text and inline data images are supported." +msgstr "仅支持文本和内联数据图片。" + +#: src/iac_code/agui/errors.py +msgid "Remote media URLs are not supported." +msgstr "不支持远程媒体 URL。" + +#: src/iac_code/agui/errors.py +msgid "The A2A context identity changed unexpectedly." +msgstr "A2A 上下文标识发生了意外变化。" + +#: src/iac_code/agui/errors.py +#, python-brace-format +msgid "The A2A execution failed." +msgstr "A2A 执行失败。" + +#: src/iac_code/agui/errors.py +msgid "The A2A interrupt response was not accepted." +msgstr "A2A 未接受中断响应。" + +#: src/iac_code/agui/errors.py +msgid "The A2A permission response was not accepted." +msgstr "A2A 未接受权限响应。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity changed unexpectedly." +msgstr "A2A 任务标识发生了意外变化。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task identity does not match the interrupted run." +msgstr "A2A 任务标识与被中断的运行不匹配。" + +#: src/iac_code/agui/errors.py +msgid "The A2A task to resume is unavailable." +msgstr "要恢复的 A2A 任务不可用。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI adapter state is unavailable." +msgstr "AG-UI 适配器状态不可用。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI run id has already been used." +msgstr "AG-UI run ID 已被使用。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread already has an active run." +msgstr "AG-UI thread 已有正在运行的任务。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is already bound to another workspace or caller." +msgstr "AG-UI thread 已绑定到其他工作区或调用方。" + +#: src/iac_code/agui/errors.py +msgid "The AG-UI thread is waiting for interrupt responses." +msgstr "AG-UI thread 正在等待中断响应。" + +#: src/iac_code/agui/errors.py +msgid "The accepted interrupt response could not be committed." +msgstr "无法持久化已接受的中断响应。" + +#: src/iac_code/agui/errors.py +msgid "The execution mapping could not be committed." +msgstr "无法持久化执行映射。" + +#: src/iac_code/agui/errors.py +msgid "The execution session mapping could not be committed." +msgstr "无法持久化执行会话映射。" + +#: src/iac_code/agui/errors.py +msgid "The execution to resume is no longer available." +msgstr "要恢复的执行已不存在。" + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled by the interrupt response." +msgstr "该执行已被中断响应取消。" + +#: src/iac_code/agui/errors.py +msgid "The execution was cancelled." +msgstr "执行已取消。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code session identity changed unexpectedly." +msgstr "iac-code 会话标识发生了意外变化。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code session to resume is unavailable." +msgstr "要恢复的 iac-code 会话不可用。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace cannot be created." +msgstr "无法创建 iac-code 工作区。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is invalid." +msgstr "iac-code 工作区无效。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is not a directory." +msgstr "iac-code 工作区不是目录。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace is outside the allowed roots." +msgstr "iac-code 工作区不在允许的根目录中。" + +#: src/iac_code/agui/errors.py +msgid "The iac-code workspace must be an absolute path." +msgstr "iac-code 工作区必须是绝对路径。" + +#: src/iac_code/agui/errors.py +msgid "The image data is invalid." +msgstr "图片数据无效。" + +#: src/iac_code/agui/errors.py +msgid "The image data is not valid base64." +msgstr "图片数据不是有效的 base64。" + +#: src/iac_code/agui/errors.py +msgid "The image media type is not supported." +msgstr "不支持该图片媒体类型。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response does not contain an answer." +msgstr "中断响应中没有答案。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response has already been applied." +msgstr "中断响应已被应用。" + +#: src/iac_code/agui/errors.py +msgid "The interrupt response payload is invalid." +msgstr "中断响应内容无效。" + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution has expired." +msgstr "被中断的执行已过期。" + +#: src/iac_code/agui/errors.py +msgid "The interrupted execution state could not be committed." +msgstr "无法持久化被中断的执行状态。" + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service is unavailable." +msgstr "本地 A2A 执行服务不可用。" + +#: src/iac_code/agui/errors.py +msgid "The local A2A execution service rejected the interrupt response." +msgstr "本地 A2A 执行服务拒绝了中断响应。" + +#: src/iac_code/agui/errors.py +msgid "The resume contains duplicate interrupt ids." +msgstr "恢复请求包含重复的中断 ID。" + +#: src/iac_code/agui/errors.py +msgid "The resume must resolve every pending interrupt exactly once." +msgstr "恢复请求必须且只能响应每个待处理中断一次。" + +#: src/iac_code/agui/errors.py +msgid "The resume references an unknown interrupt." +msgstr "恢复请求引用了未知的中断。" + +#: src/iac_code/agui/errors.py +msgid "The resume request does not match the interrupted run." +msgstr "恢复请求与被中断的运行不匹配。" + +#: src/iac_code/agui/errors.py +msgid "The total image content exceeds the maximum size." +msgstr "图片内容总大小超过上限。" + +#: src/iac_code/agui/errors.py +msgid "The user message content is invalid." +msgstr "用户消息内容无效。" + +#: src/iac_code/agui/process.py +msgid "The local A2A process is already started." +msgstr "本地 A2A 进程已启动。" + +#: src/iac_code/agui/process.py +#, python-brace-format +msgid "The local A2A process exited during startup (exit code {})." +msgstr "本地 A2A 进程在启动期间退出(退出代码 {})。" + +#: src/iac_code/agui/process.py +msgid "The local A2A process did not become ready in time." +msgstr "本地 A2A 进程未能在规定时间内就绪。" + +#: src/iac_code/agui/server.py +msgid "The AG-UI adapter may connect only to a loopback A2A HTTP(S) URL." +msgstr "AG-UI 适配器只能连接回环地址上的 A2A HTTP(S) URL。" + #: src/iac_code/cli/headless.py #, python-brace-format msgid "Tool started: {}" @@ -809,6 +1071,12 @@ msgid "" "code[a2a]'" msgstr "缺少 A2A 服务器依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" +#: src/iac_code/cli/main.py +msgid "" +"AG-UI adapter dependencies are missing. Install with: pip install 'iac-" +"code[agui]'" +msgstr "缺少 AG-UI 适配器依赖。请使用以下命令安装:pip install 'iac-code[agui]'" + #: src/iac_code/cli/main.py msgid "" "Interactive REPL input requires a terminal. The /mcp command opens an " @@ -962,6 +1230,71 @@ msgstr "将 iac-code 作为 ACP 服务器运行。" msgid "Transport type: stdio or http" msgstr "传输类型:stdio 或 http" +#: src/iac_code/cli/main.py +msgid "Unable to load the AG-UI config file." +msgstr "无法加载 AG-UI 配置文件。" + +#: src/iac_code/cli/main.py +msgid "AG-UI config file must contain a YAML mapping." +msgstr "AG-UI 配置文件必须包含 YAML 映射。" + +#: src/iac_code/cli/main.py +msgid "Run the AG-UI adapter backed by a local iac-code A2A process." +msgstr "启动由本地 iac-code A2A 进程提供执行能力的 AG-UI 适配器。" + +#: src/iac_code/cli/main.py +msgid "YAML config file for AG-UI adapter options" +msgstr "用于 AG-UI 适配器选项的 YAML 配置文件" + +#: src/iac_code/cli/main.py +msgid "Existing local A2A URL; omitted starts a managed A2A child process" +msgstr "已有的本地 A2A URL;省略时启动受管的 A2A 子进程" + +#: src/iac_code/cli/main.py +msgid "Mirror adapter logs to stdout" +msgstr "将适配器日志同步输出到 stdout" + +#: src/iac_code/cli/main.py +msgid "Seconds an AG-UI interrupt remains resumable" +msgstr "AG-UI 中断可恢复的保留秒数" + +#: src/iac_code/cli/main.py +msgid "Durable AG-UI adapter state directory" +msgstr "AG-UI 适配器持久化状态目录" + +#: src/iac_code/cli/main.py +msgid "Exit after this many idle seconds; zero disables idle shutdown" +msgstr "空闲达到该秒数后退出;设为零禁用空闲退出" + +#: src/iac_code/cli/main.py +#, python-brace-format +msgid "--port must be between 1 and 65535." +msgstr "--port 必须在 1 到 65535 之间。" + +#: src/iac_code/cli/main.py +msgid "--interrupt-ttl must be a positive integer." +msgstr "--interrupt-ttl 必须是正整数。" + +#: src/iac_code/cli/main.py +msgid "--state-dir must be a string." +msgstr "--state-dir 必须是字符串。" + +#: src/iac_code/cli/main.py +msgid "--idle-shutdown must be a non-negative number." +msgstr "--idle-shutdown 必须是非负数。" + +#: src/iac_code/cli/main.py +msgid "--a2a-url must be a string." +msgstr "--a2a-url 必须是字符串。" + +#: src/iac_code/cli/main.py +msgid "auth_token must be a string." +msgstr "auth_token 必须是字符串。" + +#: src/iac_code/cli/main.py +msgid "a2a_token must be a string." +msgstr "a2a_token 必须是字符串。" + #: src/iac_code/cli/main.py msgid "Run iac-code as an A2A 1.0 server." msgstr "将 iac-code 作为 A2A 1.0 服务器运行。" diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 0f6d4e3d..5beb50e9 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -1071,6 +1071,8 @@ def commit_success(event: MessageEndEvent, *, status: str = "ok") -> bool: if terminal_status is not None: return False terminal_status = status + event.usage.provider = provider_name + event.usage.model = sanitized_model with activate_span(): self._set_llm_response_span_attrs(span, event, model) self._emit_success_telemetry( @@ -1303,6 +1305,8 @@ def commit_failure( yield _error_event_from_exception(e) return response = completion.response + response.usage.provider = completion.provider_name + response.usage.model = sanitize_model_name(completion.model) yield MessageStartEvent(message_id=response.message_id) if response.thinking_blocks: for block_index, block in enumerate(response.thinking_blocks): diff --git a/src/iac_code/types/stream_events.py b/src/iac_code/types/stream_events.py index f161d611..287e6f7e 100644 --- a/src/iac_code/types/stream_events.py +++ b/src/iac_code/types/stream_events.py @@ -22,6 +22,8 @@ class Usage: """Provider token usage with enough metadata for normalized reporting.""" + provider: str | None = None + model: str | None = None input_tokens: int = 0 output_tokens: int = 0 cache_creation_input_tokens: int = 0 diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py index 31179f67..4c52b4cd 100644 --- a/tests/a2a/test_app.py +++ b/tests/a2a/test_app.py @@ -51,8 +51,9 @@ PermissionWaitPolicy, build_permission_checkpoint, ) -from iac_code.services.session_backup import BackupReason, SessionBackupBlocked +from iac_code.services.session_backup import BackupReason, SessionBackupBlocked, SessionBackupService from iac_code.services.session_backup_state import NORMAL_HANDOFF_PROOF_KEY, BackupPublicationProof +from iac_code.services.session_metadata import SESSION_LAYOUT_VERSION_V2, SessionMetadata, write_session_metadata from iac_code.services.session_storage import SessionStorage from iac_code.types.stream_events import TextDeltaEvent, ToolResultEvent @@ -151,6 +152,60 @@ def test_readiness_route_is_authenticated_and_returns_non_secret_status(monkeypa assert response.json() == expected +def test_ensure_session_restored_is_authenticated_idempotent_and_restores_backup(monkeypatch, tmp_path) -> None: + config_dir = tmp_path / "config" + backup_root = tmp_path / "backup" + workspace_root = tmp_path / "workspace" + cwd = workspace_root / "session" + cwd.mkdir(parents=True) + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(config_dir)) + monkeypatch.setenv("IAC_CODE_CONFIG_BACKUP_DIR", str(backup_root)) + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(workspace_root)) + + session_id = "session-restore" + backup_storage = SessionStorage(projects_dir=backup_root / "projects") + backup_session_dir = backup_storage.session_dir(str(cwd), session_id) + write_session_metadata( + backup_session_dir, + SessionMetadata(session_id=session_id, cwd=str(cwd), layout_version=SESSION_LAYOUT_VERSION_V2), + ) + SessionBackupService(session_storage=backup_storage).initialize_session(str(cwd), session_id) + pipeline_dir = backup_session_dir / "a2a" / "pipeline" + pipeline_dir.mkdir(parents=True) + (pipeline_dir / "a2a-events.jsonl").write_text('{"sequence":1}\n', encoding="utf-8") + (pipeline_dir / "a2a-snapshot.json").write_text('{"lastSequence":1}\n', encoding="utf-8") + + app = create_app( + host="127.0.0.1", + port=41242, + token="runtime-token", + model="qwen3.6-plus", + persistence_dir=config_dir / "a2a", + ) + payload = {"cwd": str(cwd), "sessionId": session_id} + headers = {"Authorization": "Bearer runtime-token"} + with TestClient(app) as client: + unauthorized = client.post("/iac-code/session/ensure-restored", json=payload) + restored = client.post("/iac-code/session/ensure-restored", json=payload, headers=headers) + current = client.post("/iac-code/session/ensure-restored", json=payload, headers=headers) + missing = client.post( + "/iac-code/session/ensure-restored", + json={"cwd": str(cwd), "sessionId": "missing-session"}, + headers=headers, + ) + + restored_pipeline_dir = SessionStorage().session_dir(str(cwd), session_id) / "a2a" / "pipeline" + assert unauthorized.status_code == 401 + assert restored.status_code == 200 + assert restored.json() == {"status": "restored"} + assert current.status_code == 200 + assert current.json() == {"status": "current"} + assert missing.status_code == 404 + assert missing.json() == {"status": "not_found"} + assert (restored_pipeline_dir / "a2a-events.jsonl").read_text(encoding="utf-8") == '{"sequence":1}\n' + assert (restored_pipeline_dir / "a2a-snapshot.json").read_text(encoding="utf-8") == '{"lastSequence":1}\n' + + @pytest.mark.asyncio async def test_a2a_task_store_writes_session_snapshots_and_global_indexes(monkeypatch, tmp_path) -> None: config_dir = tmp_path / "config" diff --git a/tests/a2a/test_client.py b/tests/a2a/test_client.py index 7c5504ce..02a25b95 100644 --- a/tests/a2a/test_client.py +++ b/tests/a2a/test_client.py @@ -73,6 +73,28 @@ async def aclose(self) -> None: self.closed = True +class PipelineStateHTTPClient(FakeHTTPClient): + def __init__(self, *, status_code: int = 200) -> None: + super().__init__() + self.status_code = status_code + self.pipeline_request = None + + async def get(self, url: str, headers=None, params=None) -> FakeHTTPResponse: + self.pipeline_request = (url, headers, params) + return FakeHTTPResponse({"snapshot": {"lastSequence": 4}, "events": []}, self.status_code) + + +class SessionRestoreHTTPClient(FakeHTTPClient): + def __init__(self, *, status_code: int = 200, status: str = "restored") -> None: + super().__init__() + self.status_code = status_code + self.status = status + + async def post(self, url: str, json: dict[str, object], headers=None) -> FakeHTTPResponse: + self.requests.append(("POST", url, json, headers)) + return FakeHTTPResponse({"status": self.status}, self.status_code) + + class HangingInputRequiredHTTPClient(FakeHTTPClient): def __init__(self) -> None: super().__init__() @@ -107,6 +129,56 @@ async def _collect_async(iterator): return [item async for item in iterator] +@pytest.mark.asyncio +async def test_get_pipeline_state_uses_read_only_http_extension_and_auth() -> None: + http = PipelineStateHTTPClient() + client = A2AClient(http_client=http, auth=A2AAuthConfig(bearer_token="secret")) + + state = await client.get_pipeline_state("http://remote/", task_id="task-1", after_sequence=3) + + assert state == {"snapshot": {"lastSequence": 4}, "events": []} + assert http.pipeline_request == ( + "http://remote/iac-code/pipeline/state", + {"A2A-Version": "1.0", "Authorization": "Bearer secret"}, + {"taskId": "task-1", "afterSequence": 3}, + ) + + +@pytest.mark.asyncio +async def test_get_pipeline_state_returns_none_when_task_has_no_pipeline_snapshot() -> None: + client = A2AClient(http_client=PipelineStateHTTPClient(status_code=404)) + + assert await client.get_pipeline_state("http://remote/", task_id="task-1") is None + + +@pytest.mark.asyncio +async def test_ensure_session_restored_uses_internal_http_extension_and_auth() -> None: + http = SessionRestoreHTTPClient() + client = A2AClient(http_client=http, auth=A2AAuthConfig(bearer_token="secret")) + + ready = await client.ensure_session_restored("http://remote/", cwd="/workspace/session", session_id="session-1") + + assert ready is True + assert http.requests == [ + ( + "POST", + "http://remote/iac-code/session/ensure-restored", + {"cwd": "/workspace/session", "sessionId": "session-1"}, + {"A2A-Version": "1.0", "Authorization": "Bearer secret"}, + ) + ] + + +@pytest.mark.asyncio +async def test_ensure_session_restored_returns_false_when_backup_is_missing() -> None: + client = A2AClient(http_client=SessionRestoreHTTPClient(status_code=404, status="not_found")) + + assert ( + await client.ensure_session_restored("http://remote/", cwd="/workspace/session", session_id="session-1") + is False + ) + + def _base64url_uint(value: int) -> str: raw = value.to_bytes((value.bit_length() + 7) // 8, "big") return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") diff --git a/tests/a2a/test_events.py b/tests/a2a/test_events.py index 3b92e2b2..9f08bf23 100644 --- a/tests/a2a/test_events.py +++ b/tests/a2a/test_events.py @@ -16,6 +16,7 @@ ) from iac_code.a2a.exposure import A2AExposureType from iac_code.a2a.input_required import PermissionInputRegistry, PermissionResponse +from iac_code.a2a.projection import project_a2a_data from iac_code.services.permission_wait import ( PermissionWaitCheckpointStore, PermissionWaitCoordinator, @@ -936,6 +937,7 @@ async def test_tool_events_publish_metadata_updates() -> None: "tool_name": "bash", "fields": {"cmd": {"type": "str"}}, } + assert dumped[2]["metadata"]["iac_code"]["tool"]["toolInput"] == {"cmd": "pwd"} assert "input" not in dumped[2]["metadata"]["iac_code"]["tool"] assert dumped[3]["metadata"]["iac_code"]["tool"]["status"] == "completed" @@ -1004,7 +1006,7 @@ async def test_tool_input_delta_metadata_omits_raw_partial_json() -> None: @pytest.mark.asyncio -async def test_tool_use_input_metadata_redacts_secret_values() -> None: +async def test_tool_use_input_metadata_preserves_values_before_wire_projection() -> None: queue = FakeEventQueue() await publish_stream_event( @@ -1020,12 +1022,11 @@ async def test_tool_use_input_metadata_redacts_secret_values() -> None: dumped = dump(queue.events[0]) tool = dumped["metadata"]["iac_code"]["tool"] - rendered = str(tool) assert "input" not in tool assert tool["inputSummary"] == {"tool_name": "bash", "fields": {"cmd": {"type": "str"}}} - assert "sk-live-secret" not in rendered - assert "Authorization: Bearer" not in rendered - assert "/Users/alice" not in rendered + assert tool["toolInput"] == { + "cmd": 'cat /Users/alice/.iac-code/settings.yml && curl -H "Authorization: Bearer sk-live-secret"' + } @pytest.mark.asyncio @@ -1080,7 +1081,7 @@ async def test_tool_use_input_metadata_redacts_structured_secret_fields() -> Non @pytest.mark.asyncio -async def test_aliyun_tool_use_input_metadata_uses_summary_for_sensitive_safe_fields() -> None: +async def test_aliyun_tool_use_input_metadata_keeps_summary_and_renderable_arguments() -> None: queue = FakeEventQueue() pem = "-----BEGIN PRIVATE KEY-----\nprivate-body\n-----END PRIVATE KEY-----" @@ -1101,21 +1102,21 @@ async def test_aliyun_tool_use_input_metadata_uses_summary_for_sensitive_safe_fi dumped = dump(queue.events[0]) tool = dumped["metadata"]["iac_code"]["tool"] - rendered = str(tool) assert "input" not in tool assert tool["inputSummary"]["tool_name"] == "aliyun_api" assert tool["inputSummary"]["params_fields"] == sorted( [fingerprint_text("StackName"), fingerprint_text("TemplateBody")] ) assert tool["inputSummary"]["params_field_count"] == 2 - assert "StackName" not in rendered - assert "TemplateBody" not in rendered - assert "private-body" not in rendered - assert "BEGIN PRIVATE KEY" not in rendered + assert tool["toolInput"] == { + "product": "ros", + "action": "CreateStack", + "params": {"TemplateBody": pem, "StackName": "demo"}, + } @pytest.mark.asyncio -async def test_tool_use_input_metadata_redacts_sensitive_keys() -> None: +async def test_tool_use_input_metadata_preserves_renderable_business_arguments() -> None: queue = FakeEventQueue() await publish_stream_event( @@ -1124,12 +1125,15 @@ async def test_tool_use_input_metadata_redacts_sensitive_keys() -> None: context_id="ctx-1", event=ToolUseEndEvent( tool_use_id="tool-1", - name="bash", + name="ros_stack", input={ - "cmd": "pwd", - "apiKey": "plain-api-key", - "env": {"ALIBABA_CLOUD_ACCESS_KEY_SECRET": "ak-secret"}, - "headers": [{"x-acs-security-token": "sts-token"}], + "action": "CreateStack", + "params": { + "DisableRollback": True, + "StackName": "demo-stack", + "TemplateBody": {"ROSTemplateFormatVersion": "2015-09-01"}, + }, + "region_id": "cn-hangzhou", }, ), ) @@ -1137,23 +1141,50 @@ async def test_tool_use_input_metadata_redacts_sensitive_keys() -> None: dumped = dump(queue.events[0]) tool = dumped["metadata"]["iac_code"]["tool"] assert "input" not in tool - summary = tool["inputSummary"] - fields = summary["fields"] - assert summary["tool_name"] == "bash" - assert fields["cmd"] == {"type": "str"} - assert fields[fingerprint_text("apiKey")] == {"type": "str"} - assert fields[fingerprint_text("env")]["type"] == "object" - assert fields["headers"] == {"type": "array", "length": 1} - rendered = str(tool) - assert "apiKey" not in rendered - assert "env" not in rendered - assert "plain-api-key" not in rendered - assert "ak-secret" not in rendered - assert "sts-token" not in rendered + assert tool["toolInput"] == { + "action": "CreateStack", + "params": { + "DisableRollback": True, + "StackName": "demo-stack", + "TemplateBody": {"ROSTemplateFormatVersion": "2015-09-01"}, + }, + "region_id": "cn-hangzhou", + } + + +@pytest.mark.asyncio +async def test_tool_use_input_safe_mode_only_projects_paths() -> None: + queue = FakeEventQueue() + tool_input = { + "action": "CreateStack", + "path": "/workspace/template.yaml", + "params": {"DisableRollback": True, "Password": "fake-secret", "StackName": "demo-stack"}, + } + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolUseEndEvent(tool_use_id="tool-1", name="ros_stack", input=tool_input), + ) + + canonical = dump(queue.events[0]) + projected = project_a2a_data( + canonical, + public_path_roots=[{"path": "/workspace", "label": "."}], + safe_mode=True, + ) + projected_input = projected["metadata"]["iac_code"]["tool"]["toolInput"] + assert projected_input == { + "action": "CreateStack", + "path": "[PATH]", + "params": {"DisableRollback": True, "Password": "fake-secret", "StackName": "demo-stack"}, + } + assert canonical["metadata"]["iac_code"]["tool"]["toolInput"] == tool_input @pytest.mark.asyncio -async def test_tool_use_input_metadata_redacts_malformed_opaque_artifact_uri() -> None: +async def test_tool_use_input_metadata_keeps_malformed_opaque_artifact_uri_before_wire_projection() -> None: queue = FakeEventQueue() malformed_uri = r"iac-code-artifact://artifact-1/C:\Users\alice\.iac-code\projects\demo\template.yaml" @@ -1170,17 +1201,14 @@ async def test_tool_use_input_metadata_redacts_malformed_opaque_artifact_uri() - dumped = dump(queue.events[0]) tool = dumped["metadata"]["iac_code"]["tool"] - rendered = str(tool) assert "input" not in tool assert tool["inputSummary"]["fields"]["cmd"] == {"type": "str"} assert tool["inputSummary"]["fields"][fingerprint_text("note")] == {"type": "str"} - assert "iac-code-artifac[PATH]" not in rendered - assert "Users" not in rendered - assert ".iac-code" not in rendered + assert tool["toolInput"] == {"cmd": f"cat {malformed_uri}", "note": malformed_uri} @pytest.mark.asyncio -async def test_tool_use_input_metadata_redacts_percent_encoded_local_path() -> None: +async def test_tool_use_input_metadata_keeps_percent_encoded_path_before_wire_projection() -> None: queue = FakeEventQueue() encoded_path = "file%3A%2F%2F%2FUsers%2Falice%2F.iac-code%2Fprojects%2Fdemo%2Ftemplate.yaml" @@ -1197,15 +1225,13 @@ async def test_tool_use_input_metadata_redacts_percent_encoded_local_path() -> N dumped = dump(queue.events[0]) tool = dumped["metadata"]["iac_code"]["tool"] - rendered = str(tool) assert "input" not in tool assert tool["inputSummary"]["fields"]["cmd"] == {"type": "str"} - assert "%2FUsers" not in rendered - assert ".iac-code" not in rendered + assert tool["toolInput"] == {"cmd": f"cat {encoded_path}"} @pytest.mark.asyncio -async def test_tool_use_input_summary_fingerprints_business_field_names() -> None: +async def test_tool_use_input_summary_fingerprints_names_without_corrupting_tool_input() -> None: queue = FakeEventQueue() await publish_stream_event( @@ -1224,17 +1250,17 @@ async def test_tool_use_input_summary_fingerprints_business_field_names() -> Non ) tool = dump(queue.events[0])["metadata"]["iac_code"]["tool"] - rendered = str(tool) assert "input" not in tool assert tool["inputSummary"]["tool_name"] == "bash" fields = tool["inputSummary"]["fields"] assert fields["cmd"] == {"type": "str"} assert fields[fingerprint_text("customerEmail")] == {"type": "str"} assert fields[fingerprint_text("customer-prod-123")] == {"type": "str"} - assert "customerEmail" not in rendered - assert "customer-prod-123" not in rendered - assert "alice@example.com" not in rendered - assert "tenant-id" not in rendered + assert tool["toolInput"] == { + "cmd": "git status", + "customerEmail": "alice@example.com", + "customer-prod-123": "tenant-id", + } @pytest.mark.asyncio @@ -1833,11 +1859,16 @@ async def test_message_end_publishes_usage_metadata() -> None: queue, task_id="task-1", context_id="ctx-1", - event=MessageEndEvent(stop_reason="end_turn", usage=Usage(input_tokens=2, output_tokens=3)), + event=MessageEndEvent( + stop_reason="end_turn", + usage=Usage(provider="dashscope", model="qwen", input_tokens=2, output_tokens=3), + ), ) dumped = dump(queue.events[0]) assert dumped["metadata"]["iac_code"]["usage"]["totalTokens"] == 5 + assert dumped["metadata"]["iac_code"]["usage"]["provider"] == "dashscope" + assert dumped["metadata"]["iac_code"]["usage"]["model"] == "qwen" @pytest.mark.asyncio diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index 0ab2c87c..6e162b73 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -2204,7 +2204,7 @@ async def test_executor_delegates_pipeline_mode_after_validation( from iac_code.services.telemetry.attributes import AttributeBuilder from iac_code.services.telemetry.identity import Identity - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setenv("IAC_CODE_MODE", "normal") monkeypatch.setenv("IAC_CODE_A2A_SAFE_MODE", "1") monkeypatch.setenv("IAC_CODE_CHANNEL", "environment") calls = [] @@ -2250,6 +2250,7 @@ async def execute( metadata={ "iac_code": { "cwd": str(tmp_path), + "run_mode": "pipeline", "channel": "a2a-pipeline", "user_id": "client-user", "iac_code_model": "metadata-model", @@ -4493,3 +4494,96 @@ async def fail_rebuild(**_kwargs): ) assert store_calls == [] + + +@pytest.mark.asyncio +async def test_restart_identity_validation_uses_resume_request_cloud_credential(monkeypatch, tmp_path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + task_record = SimpleNamespace(context_id="ctx-1") + context_record = SimpleNamespace(cwd=str(tmp_path), session_id="session-1") + + async def get_task_record(_task_id): + return task_record + + async def get_context_record(_context_id): + return context_record + + monkeypatch.setattr(store, "get_task_record", get_task_record) + monkeypatch.setattr(store, "get_context_record", get_context_record) + checkpoint = { + "boundaryId": "pwb-boundary1", + "phase": "SUSPENDED", + "permissionClass": "normal", + "decision": {"status": "none", "value": None}, + "principalRef": "client-principal", + "region": "cn-beijing", + } + + class CheckpointStore: + def find(self, **_kwargs): + return checkpoint + + def reconcile_deadline(self, *_args, **_kwargs): + raise RuntimeError("identity validation completed") + + monkeypatch.setattr( + "iac_code.a2a.executor.PermissionWaitCheckpointStore", + lambda *_args, **_kwargs: CheckpointStore(), + ) + monkeypatch.setattr( + "iac_code.a2a.executor.recover_permission_audit_boundary", + lambda *_args, **_kwargs: RecoveredPermissionAuditBoundary( + tool_name="aliyun_api", + tool_input={"region_id": "cn-beijing"}, + tool_use_id="tool-1", + audit_context={"session_id": "session-1", "cwd": str(tmp_path)}, + ), + ) + + async def rebuild(**_kwargs): + return SimpleNamespace( + tool_name="aliyun_api", + tool_input={"region_id": "cn-beijing"}, + permission_result=SimpleNamespace(audit=None), + ) + + seen_access_key_ids: list[str | None] = [] + + def identity(**_kwargs): + from iac_code.services.providers.aliyun import AliyunCredentials + + credential = AliyunCredentials.load() + seen_access_key_ids.append(credential.access_key_id if credential is not None else None) + return "client-principal", "cn-beijing" + + monkeypatch.setattr(executor, "_rebuild_normal_permission_audit_event", rebuild) + monkeypatch.setattr("iac_code.a2a.executor.permission_execution_identity", identity) + monkeypatch.setattr( + "iac_code.services.providers.aliyun.AliyunCredentials._load_from_iac_code_config", + lambda: None, + ) + response = PermissionResponse( + task_id="task-1", + context_id="ctx-1", + request_task_id="task-1", + input_id="input-1", + tool_use_id="tool-1", + decision="deny", + ) + context = FakeRequestContext( + task_id="task-1", + context_id="ctx-1", + metadata={ + "iac_code": { + "alibaba_cloud_access_key_id": "client-id", + "alibaba_cloud_access_key_secret": "client-secret", + "alibaba_cloud_region_id": "cn-beijing", + } + }, + ) + + with pytest.raises(RuntimeError, match="identity validation completed"): + await executor._resume_persisted_permission(context, FakeEventQueue(), response=response) + + assert seen_access_key_ids == ["client-id"] diff --git a/tests/a2a/test_input_required.py b/tests/a2a/test_input_required.py index 14b5fe3f..05f7320b 100644 --- a/tests/a2a/test_input_required.py +++ b/tests/a2a/test_input_required.py @@ -953,6 +953,31 @@ def test_legacy_pending_permission_gets_conservative_display_fallback() -> None: assert projected["isReadOnly"] is False +def test_candidate_permission_projection_preserves_sideband_coordinates() -> None: + envelope = { + "eventId": "evt-candidate-permission", + "eventType": "permission_requested", + "taskId": "task-1", + "contextId": "ctx-1", + "scope": "candidate", + "candidate": {"id": "candidate-a"}, + "status": "working", + "permission": { + "pending": True, + "inputId": "permission-candidate-a", + "toolUseId": "tool-a", + "toolName": "bash", + "safeSummary": "bash: pwd", + }, + } + + projected = _unified_input_projection(envelope) + + assert projected is not None + assert projected["scope"] == "candidate" + assert projected["subPipelineId"] == "candidate-a" + + def test_candidate_selection_projection_can_use_runtime_step_ui_mode_without_mutating_envelope() -> None: envelope = { "eventId": "evt-1", @@ -983,6 +1008,7 @@ def test_ask_question_projection_preserves_free_text_contract() -> None: "input": { "kind": "ask_user_question", "inputId": "question-1", + "toolUseId": "call-question-1", "prompt": "Choose or describe", "allowFreeText": True, "freeTextPrompt": "Describe the custom region", @@ -991,6 +1017,7 @@ def test_ask_question_projection_preserves_free_text_contract() -> None: } projected = _unified_input_projection(envelope) assert projected is not None + assert projected["toolUseId"] == "call-question-1" assert projected["allowFreeText"] is True assert projected["freeTextPrompt"] == "Describe the custom region" diff --git a/tests/a2a/test_pipeline_events_context_usage.py b/tests/a2a/test_pipeline_events_context_usage.py index 9b48b6b4..c85f0e56 100644 --- a/tests/a2a/test_pipeline_events_context_usage.py +++ b/tests/a2a/test_pipeline_events_context_usage.py @@ -6,7 +6,7 @@ from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator, _usage_data from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType -from iac_code.types.stream_events import ContextUsageEvent +from iac_code.types.stream_events import ContextUsageEvent, MessageEndEvent, Usage _SNAKE = { "system_prompt_tokens": 100, @@ -89,3 +89,31 @@ def test_parent_scoped_context_usage_envelope(pipeline_translator_with_active_st assert env["status"] == "working" assert env["data"]["totalTokens"] == 1500 assert env["step"]["id"] == step_id + + +def test_provider_usage_envelope_preserves_model_and_token_counts(pipeline_translator_with_active_step): + translator, step_id = pipeline_translator_with_active_step + envelopes = translator.translate( + MessageEndEvent( + stop_reason="end_turn", + usage=Usage( + provider="dashscope", + model="qwen-test", + input_tokens=12, + output_tokens=3, + cache_read_input_tokens=2, + ), + ) + ) + + assert len(envelopes) == 1 + assert envelopes[0]["eventType"] == "usage" + assert envelopes[0]["step"]["id"] == step_id + assert envelopes[0]["data"] == { + "provider": "dashscope", + "model": "qwen-test", + "inputTokens": 12, + "outputTokens": 3, + "totalTokens": 17, + "cachedInputTokens": 2, + } diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py index 188f4c75..7958d78f 100644 --- a/tests/a2a/test_transport_dispatcher.py +++ b/tests/a2a/test_transport_dispatcher.py @@ -845,7 +845,7 @@ async def test_dispatcher_stream_preserves_message_metadata_echo_without_safe_mo @pytest.mark.asyncio async def test_dispatcher_rejects_pipeline_image_before_executor_runs(monkeypatch, tmp_path) -> None: - monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setenv("IAC_CODE_MODE", "normal") monkeypatch.setattr( "iac_code.a2a.parts.maybe_resize_and_downsample", lambda raw: SimpleNamespace(data=raw, media_type="image/png"), @@ -878,7 +878,7 @@ async def fail_if_called(*args, **kwargs): "mediaType": "image/png", } ], - "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + "metadata": {"iac_code": {"cwd": str(tmp_path), "run_mode": "pipeline"}}, }, "configuration": {"acceptedOutputModes": ["text/plain"]}, }, diff --git a/tests/a2a_e2e/test_start_chat_permission_wait_runner.py b/tests/a2a_e2e/test_start_chat_permission_wait_runner.py index 68038426..f47d590d 100644 --- a/tests/a2a_e2e/test_start_chat_permission_wait_runner.py +++ b/tests/a2a_e2e/test_start_chat_permission_wait_runner.py @@ -315,6 +315,8 @@ def test_real_runner_uses_repository_prompt_with_run_scoped_names() -> None: "stack_name": "pwait-normal-1234-stack", "vswitch_name": "pwait-normal-1234-vsw", "mode": "Normal", + "mode_arg": "normal", + "state_dir": "/tmp/pwait-normal-state", }, ) @@ -323,6 +325,10 @@ def test_real_runner_uses_repository_prompt_with_run_scoped_names() -> None: assert "pwait-normal-1234-vsw" in prompt assert "Mermaid" in prompt assert "不要创建或删除 VPC" in prompt + assert "ALICLOUD_ROS_AGENT_STATE_DIR=/tmp/pwait-normal-state" in prompt + assert "--mode normal" in prompt + assert "一次 readiness `check`" in prompt + assert "整个测试只能执行一次 managed `start`" in prompt pipeline_prompt = runner._prompt_section( "Deployment", { @@ -330,10 +336,13 @@ def test_real_runner_uses_repository_prompt_with_run_scoped_names() -> None: "stack_name": "pwait-pipeline-1234-stack", "vswitch_name": "pwait-pipeline-1234-vsw", "mode": "Pipeline", + "mode_arg": "pipeline", + "state_dir": "/tmp/pwait-pipeline-state", }, ) assert "恰好两个" in pipeline_prompt assert "不同可用区" in pipeline_prompt + assert "--mode pipeline" in pipeline_prompt def test_real_runner_refuses_incomplete_read_only_unknown_and_out_of_scope_permissions(tmp_path) -> None: @@ -613,9 +622,25 @@ def test_real_runner_records_assistant_diagram_and_cloud_permission_block_order( stdout = "\n".join( json.dumps(item) for item in ( - { - "type": "assistant", - "message": {"content": [{"type": "text", "text": "```mermaid\ngraph TD\n```"}]}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "```mermaid\ngraph TD\n```"}, + { + "type": "tool_use", + "id": "bridge-1", + "name": "Bash", + "input": { + "command": ( + "ALICLOUD_ROS_AGENT_STATE_DIR=/tmp/state python3 " + "/repo/skills/alicloud-ros-agent/scripts/ros_agent.py " + "start --prompt-file request.txt --mode normal --follow" + ) + }, + }, + ] + }, }, { "type": "user", @@ -638,11 +663,13 @@ def test_real_runner_records_assistant_diagram_and_cloud_permission_block_order( }, ) ) - monkeypatch.setattr( - runner.subprocess, - "run", - lambda *_args, **_kwargs: SimpleNamespace(returncode=0, stdout=stdout, stderr=""), - ) + captured_command = [] + + def run_qoder(command, **_kwargs): + captured_command.extend(command) + return SimpleNamespace(returncode=0, stdout=stdout, stderr="") + + monkeypatch.setattr(runner.subprocess, "run", run_qoder) workspace = tmp_path / "workspace" workspace.mkdir() @@ -652,7 +679,7 @@ def test_real_runner_records_assistant_diagram_and_cloud_permission_block_order( qoder_config_dir=tmp_path / "qoder-config", qoder_turn_timeout=30, ), - env={}, + env={"ALICLOUD_ROS_AGENT_STATE_DIR": "/tmp/state"}, workspace=workspace, session_id="session-1", prompt="test", @@ -662,4 +689,16 @@ def test_real_runner_records_assistant_diagram_and_cloud_permission_block_order( ) assert evidence["firstMermaidBlockIndex"] == 1 - assert evidence["firstCloudPermissionBlockIndex"] == 2 + assert evidence["firstCloudPermissionBlockIndex"] == 3 + assert evidence["bridgeCommandCount"] == 1 + assert evidence["bridgeManagedStart"] is True + assert evidence["bridgeManagedStartCount"] == 1 + assert evidence["bridgeCheckCount"] == 0 + assert evidence["bridgeStateDirBound"] is True + assert evidence["bridgeStateDirBoundCount"] == 1 + assert evidence["bridgeStartShapeOk"] is True + assert evidence["bridgeScriptPathKinds"] == ["repository"] + policy = captured_command[captured_command.index("--append-system-prompt") + 1] + assert "Execute at most one ros_agent.py bridge command" in policy + assert "managed start exactly once" in policy + assert "ALICLOUD_ROS_AGENT_STATE_DIR=/tmp/state" in policy diff --git a/tests/agui/__init__.py b/tests/agui/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/agui/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/agui/test_app.py b/tests/agui/test_app.py new file mode 100644 index 00000000..60123ca2 --- /dev/null +++ b/tests/agui/test_app.py @@ -0,0 +1,1200 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx +import pytest +from starlette.testclient import TestClient + +from iac_code.a2a.app import create_app as create_a2a_app +from iac_code.a2a.client import A2AClientResponse +from iac_code.agui.adapter import AguiA2AAdapter, ThreadBinding +from iac_code.agui.app import create_app +from iac_code.agui.events import A2AEventMapper, a2a_state +from iac_code.types.stream_events import TextDeltaEvent +from tests.a2a.fakes import FakeAgentLoop, FakeRuntime + + +class FakeA2AClient: + def __init__(self, *, interrupt: bool = False, input_value: dict[str, Any] | None = None) -> None: + self.interrupt = interrupt + self.input_value = input_value + self.sent_parts: list[dict[str, Any]] = [] + self.resumed_prompts: list[tuple[str, str | None]] = [] + self.stream_contexts: list[str] = [] + self.stream_options: list[dict[str, Any]] = [] + self.cancelled: list[str] = [] + self.restored_sessions: list[tuple[str, str]] = [] + self.resume_preflight_calls: list[str] = [] + self.session_available = True + self.closed = False + + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + self.stream_contexts.append(context_id) + self.stream_options.append(kwargs) + + async def events(): + if kwargs.get("task_id") is not None: + self.sent_parts.extend(_parts) + yield _text_event(context_id=context_id, text="resumed") + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + return + yield _event(context_id=context_id) + if self.interrupt: + yield _permission_event(context_id=context_id) + return + if self.input_value is not None: + value = dict(self.input_value) + value["contextId"] = context_id + value["requestTaskId"] = "task-1" + tool_use_id = value.get("toolUseId") + if isinstance(tool_use_id, str) and tool_use_id: + yield _tool_event( + context_id=context_id, + value={"status": "started", "toolUseId": tool_use_id, "name": "ask_user_question"}, + ) + yield _tool_event( + context_id=context_id, + value={ + "status": "input_complete", + "toolUseId": tool_use_id, + "name": "ask_user_question", + "toolInput": {"prompt": value.get("prompt")}, + }, + ) + yield _input_event(context_id=context_id, value=value) + return + yield _text_event(context_id=context_id, text="hello") + yield _tool_event( + context_id=context_id, + value={"status": "started", "toolUseId": "tool-1", "name": "bash"}, + ) + yield _tool_event( + context_id=context_id, + value={ + "status": "input_complete", + "toolUseId": "tool-1", + "name": "bash", + "toolInput": {"command": "pwd"}, + }, + ) + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + + return events() + + def stream_message(self, _url, prompt, *, context_id, task_id=None, **_kwargs): + self.resumed_prompts.append((prompt, task_id)) + + async def events(): + yield _text_event(context_id=context_id, text="resumed") + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + + return events() + + async def send_message_parts(self, _url, parts, **_kwargs): + self.sent_parts.extend(parts) + return object() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + self.resume_preflight_calls.append("get_task") + if self.interrupt: + return _permission_event(context_id=self.context_id) + if self.input_value is not None: + value = dict(self.input_value) + value["contextId"] = self.context_id + value["requestTaskId"] = "task-1" + return _input_event(context_id=self.context_id, value=value) + return _event(context_id=self.context_id) + + async def get_pipeline_state(self, _url, *, task_id, after_sequence=None): + del task_id, after_sequence + self.resume_preflight_calls.append("get_pipeline_state") + return None + + async def ensure_session_restored(self, _url, *, cwd, session_id): + self.restored_sessions.append((cwd, session_id)) + self.resume_preflight_calls.append("ensure_session_restored") + return self.session_available + + def subscribe_task(self, _url, _task_id): + async def events(): + yield _text_event(context_id=self.context_id, text="resumed") + yield _event(context_id=self.context_id, state="TASK_STATE_INPUT_REQUIRED") + + return events() + + async def cancel_task(self, _url, task_id): + self.cancelled.append(task_id) + return {} + + async def aclose(self): + self.closed = True + + @property + def context_id(self) -> str: + return getattr(self, "_context_id", "") + + @context_id.setter + def context_id(self, value: str) -> None: + self._context_id = value + + +def _event(*, context_id: str, state: str = "TASK_STATE_WORKING") -> dict[str, Any]: + return { + "result": { + "taskId": "task-1", + "contextId": context_id, + "status": {"state": state}, + "metadata": {"iac_code": {"iacCodeSessionId": "session-1"}}, + } + } + + +def _text_event(*, context_id: str, text: str) -> dict[str, Any]: + return { + "result": { + "taskId": "task-1", + "contextId": context_id, + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "messageId": "assistant-1", + "role": "ROLE_AGENT", + "parts": [{"text": text}], + }, + }, + } + } + + +def _tool_event(*, context_id: str, value: dict[str, Any]) -> dict[str, Any]: + event = _event(context_id=context_id) + event["result"]["metadata"] = {"iac_code": {"tool": value}} + return event + + +def _permission_event(*, context_id: str) -> dict[str, Any]: + event = _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + event["result"]["metadata"] = { + "iac_code": { + "input": { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": context_id, + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "bash", + "title": "Run a local shell command", + "purpose": "Execute a command for this task.", + "effect": "local_execution", + "target": "the current workspace", + "isReadOnly": False, + "prompt": "Run a local shell command. Allow once?", + "safeSummary": "bash: pwd", + "options": [{"id": "allow_once", "label": "Allow once"}, {"id": "deny", "label": "Deny"}], + "required": True, + } + } + } + return event + + +def _input_event(*, context_id: str, value: dict[str, Any]) -> dict[str, Any]: + event = _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + event["result"]["metadata"] = {"iac_code": {"input": value}} + return event + + +def _payload(tmp_path, *, run_id: str = "run-1", resume: list[dict[str, Any]] | None = None): + return { + "threadId": "thread-1", + "runId": run_id, + "state": {}, + "messages": [] if resume else [{"id": "message-1", "role": "user", "content": "hello"}], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": str(tmp_path), + } + }, + **({"resume": resume} if resume is not None else {}), + } + + +def _events(response: httpx.Response) -> list[dict[str, Any]]: + return [json.loads(line.removeprefix("data: ")) for line in response.text.splitlines() if line.startswith("data: ")] + + +@pytest.fixture(autouse=True) +def _isolated_agui_state(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_STATE_DIR", str(tmp_path / "agui-state")) + + +@pytest.mark.asyncio +async def test_normal_run_is_translated_from_a2a_to_standard_agui(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + payload = _payload(tmp_path) + payload["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/", json=payload) + + events = _events(response) + assert response.status_code == 200 + assert events[0]["type"] == "RUN_STARTED" + assert events[-1]["type"] == "RUN_FINISHED" + assert events[-1]["outcome"] == {"type": "success"} + assert "TEXT_MESSAGE_CONTENT" in [event["type"] for event in events] + assert "TOOL_CALL_ARGS" in [event["type"] for event in events] + assert fake.stream_options[0]["iac_code_metadata"]["run_mode"] == "pipeline" + + +@pytest.mark.asyncio +async def test_pipeline_steps_are_balanced_across_interrupt_and_resume_runs(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class PipelineInterruptClient(FakeA2AClient): + def stream_message_parts(self, _url, parts, *, context_id, **kwargs): + self.context_id = context_id + self.stream_contexts.append(context_id) + self.stream_options.append(kwargs) + + async def events(): + if kwargs.get("task_id") is not None: + self.sent_parts.extend(parts) + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + return + yield _event(context_id=context_id) + event = _event(context_id=context_id) + permission = _permission_event(context_id=context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"]["iac_code"].update( + { + "pipelineBatch": { + "events": [ + { + "eventId": "parent-start", + "eventType": "step_started", + "sequence": 1, + "step": {"id": "evaluate_candidates"}, + }, + { + "eventId": "candidate-start", + "eventType": "candidate_step_started", + "sequence": 2, + "candidate": {"runId": "candidate-0"}, + "candidateStep": {"id": "template_generating"}, + }, + ] + }, + "input": permission, + } + ) + yield event + + return events() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + return _permission_event(context_id=self.context_id) + + async def get_pipeline_state(self, _url, *, task_id, after_sequence=None): + del task_id + assert after_sequence in {2, 4} + return { + "snapshot": {"pipelineRunId": "pipeline-1", "lastSequence": 4}, + "events": [ + { + "eventId": "candidate-complete", + "eventType": "candidate_step_completed", + "sequence": 3, + "candidate": {"runId": "candidate-0"}, + "candidateStep": {"id": "template_generating"}, + }, + { + "eventId": "parent-complete", + "eventType": "step_completed", + "sequence": 4, + "step": {"id": "evaluate_candidates"}, + }, + ], + } + + def assert_balanced(events: list[dict[str, Any]]) -> None: + active: set[str] = set() + for event in events: + if event["type"] == "STEP_STARTED": + assert event["stepName"] not in active + active.add(event["stepName"]) + elif event["type"] == "STEP_FINISHED": + assert event["stepName"] in active + active.remove(event["stepName"]) + elif event["type"] == "RUN_FINISHED": + assert not active + + fake = PipelineInterruptClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + initial = _payload(tmp_path) + initial["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + resume = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + resume["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = _events(await client.post("/", json=initial)) + second = _events(await client.post("/", json=resume)) + + assert first[-1]["outcome"]["type"] == "interrupt" + assert second[-1]["outcome"] == {"type": "success"} + assert_balanced(first) + assert_balanced(second) + assert adapter._threads["thread-1"].pipeline_open_steps == set() + + +@pytest.mark.asyncio +async def test_permission_resume_is_sent_to_same_a2a_task_then_resubscribed(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + second = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + first_events = _events(first) + second_events = _events(second) + assert first_events[-1]["outcome"]["type"] == "interrupt" + assert first_events[-1]["outcome"]["interrupts"][0]["message"] == "Run a local shell command. Allow once?" + assert fake.sent_parts[0]["data"]["decision"] == "allow_once" + assert fake.sent_parts[0]["data"]["requestTaskId"] == "task-1" + assert second_events[-1]["outcome"] == {"type": "success"} + + +@pytest.mark.asyncio +async def test_resume_does_not_replay_the_last_a2a_status_message(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class SnapshotTextClient(FakeA2AClient): + def stream_message_parts(self, _url, parts, *, context_id, **kwargs): + self.context_id = context_id + + async def events(): + if kwargs.get("task_id") is not None: + self.sent_parts.extend(parts) + yield _text_event(context_id=context_id, text="before interruptafter resume") + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + return + yield _event(context_id=context_id) + yield _text_event(context_id=context_id, text="before interrupt") + yield _permission_event(context_id=context_id) + + return events() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + return _permission_event(context_id=self.context_id) + + fake = SnapshotTextClient() + state_dir = tmp_path / "state" + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + first_app = create_app(adapter=first_adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=first_app), base_url="http://test") as client: + first = _events(await client.post("/", json=_payload(tmp_path))) + await first_adapter.aclose() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + second_app = create_app(adapter=second_adapter) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=second_app), base_url="http://test") as client: + second = _events( + await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + ) + await second_adapter.aclose() + + first_text = [event["delta"] for event in first if event["type"] == "TEXT_MESSAGE_CONTENT"] + second_text = [event["delta"] for event in second if event["type"] == "TEXT_MESSAGE_CONTENT"] + assert first_text == ["before interrupt"] + assert second_text == ["after resume"] + + +@pytest.mark.asyncio +async def test_invalid_permission_resume_can_be_corrected_without_accepting_execution(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + invalid = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-invalid", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow"}, + } + ], + ), + ) + invalid_events = _events(invalid) + assert [event["type"] for event in invalid_events] == ["RUN_STARTED", "RUN_ERROR"] + assert invalid_events[-1]["code"] == "RESUME_PAYLOAD_INVALID" + assert set(adapter._threads["thread-1"].pending) == {"permission-1"} + corrected = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-corrected", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + assert adapter._threads["thread-1"].pending == {} + assert _events(corrected)[-1]["outcome"] == {"type": "success"} + assert fake.sent_parts[-1]["data"]["decision"] == "allow_once" + + +@pytest.mark.asyncio +async def test_permission_resume_without_an_a2a_event_keeps_interrupt_for_retry(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class RetryablePermissionClient(FakeA2AClient): + fail_resume = True + + def stream_message_parts(self, url, parts, *, context_id, **kwargs): + if kwargs.get("task_id") is None or not self.fail_resume: + return super().stream_message_parts(url, parts, context_id=context_id, **kwargs) + + async def events(): + if False: + yield {} + + return events() + + fake = RetryablePermissionClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + response = { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + failed = await client.post("/", json=_payload(tmp_path, run_id="run-failed", resume=[response])) + assert [event["type"] for event in _events(failed)] == ["RUN_STARTED", "RUN_ERROR"] + assert _events(failed)[-1]["code"] == "A2A_UNAVAILABLE" + assert set(adapter._threads["thread-1"].pending) == {"permission-1"} + + fake.fail_resume = False + retried = await client.post("/", json=_payload(tmp_path, run_id="run-retried", resume=[response])) + + assert _events(retried)[-1]["outcome"] == {"type": "success"} + assert any(event.get("name") == "iac-code.session.v1" for event in _events(retried)) + + +@pytest.mark.asyncio +async def test_permission_resume_jsonrpc_error_is_not_accepted(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class RejectedPermissionClient(FakeA2AClient): + def stream_message_parts(self, url, parts, *, context_id, **kwargs): + if kwargs.get("task_id") is None: + return super().stream_message_parts(url, parts, context_id=context_id, **kwargs) + + async def events(): + yield {"jsonrpc": "2.0", "id": "resume", "error": {"code": -32602, "message": "rejected"}} + + return events() + + fake = RejectedPermissionClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + failed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-failed", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "deny"}, + } + ], + ), + ) + + events = _events(failed) + assert [event["type"] for event in events] == ["RUN_STARTED", "RUN_ERROR"] + assert events[-1]["code"] == "A2A_UNAVAILABLE" + assert set(adapter._threads["thread-1"].pending) == {"permission-1"} + + +@pytest.mark.asyncio +async def test_top_pipeline_permission_resume_uses_streaming_resume(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + initial = _payload(tmp_path) + initial["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + resume = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + resume["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=initial) + fake.context_id = adapter._threads["thread-1"].context_id + response = await client.post("/", json=resume) + + assert _events(response)[-1]["outcome"] == {"type": "success"} + assert fake.sent_parts[-1]["data"]["decision"] == "allow_once" + assert len(fake.stream_contexts) == 2 + + +@pytest.mark.asyncio +async def test_sub_pipeline_permission_resume_uses_sideband_send_then_resubscribes(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class SubPipelinePermissionClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + if kwargs.get("task_id") is not None: + return super().stream_message_parts(_url, _parts, context_id=context_id, **kwargs) + self.stream_contexts.append(context_id) + self.stream_options.append(kwargs) + + async def events(): + yield _event(context_id=context_id) + event = _event(context_id=context_id) + permission = _permission_event(context_id=context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + yield event + + return events() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + event = _event(context_id=self.context_id) + permission = _permission_event(context_id=self.context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + return event + + fake = SubPipelinePermissionClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + initial = _payload(tmp_path) + initial["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + resume = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + resume["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=initial) + fake.context_id = adapter._threads["thread-1"].context_id + response = await client.post("/", json=resume) + + assert _events(response)[-1]["outcome"] == {"type": "success"} + assert fake.sent_parts[-1]["data"]["decision"] == "allow_once" + assert len(fake.stream_contexts) == 1 + + +@pytest.mark.asyncio +async def test_sub_pipeline_jsonrpc_error_keeps_permission_pending(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class RejectedSidebandClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + del kwargs + + async def events(): + yield _event(context_id=context_id) + event = _event(context_id=context_id) + permission = _permission_event(context_id=context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + yield event + + return events() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + event = _event(context_id=self.context_id) + permission = _permission_event(context_id=self.context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + return event + + async def send_message_parts(self, _url, _parts, **_kwargs): + return A2AClientResponse( + payload={"jsonrpc": "2.0", "id": "resume", "error": {"code": -32602, "message": "rejected"}} + ) + + fake = RejectedSidebandClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + failed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-failed", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "deny"}, + } + ], + ), + ) + + events = _events(failed) + assert [event["type"] for event in events] == ["RUN_STARTED", "RUN_ERROR"] + assert events[-1]["code"] == "A2A_UNAVAILABLE" + assert set(adapter._threads["thread-1"].pending) == {"permission-1"} + + +def test_pending_permission_is_upgraded_when_it_later_appears_as_sideband(tmp_path) -> None: + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient()) + binding = ThreadBinding( + thread_id="thread-1", + context_id="context-1", + cwd=str(tmp_path), + user_id=None, + ros_invocation_id="invocation-1", + task_id="task-1", + ) + permission = _permission_event(context_id="context-1")["result"]["metadata"]["iac_code"]["input"] + + adapter._merge_pending(binding, [permission], replace=False, sideband_ids=set()) + assert binding.pending["permission-1"].sideband is False + + adapter._merge_pending(binding, [permission], replace=False, sideband_ids={"permission-1"}) + assert binding.pending["permission-1"].sideband is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "subscription_events", + [ + [], + [{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Task is already completed"}}], + ], +) +async def test_sub_pipeline_subscribe_failure_refetches_completed_task( + tmp_path, + monkeypatch, + subscription_events, +) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class CompletedBetweenGetAndSubscribeClient(FakeA2AClient): + def __init__(self) -> None: + super().__init__() + self.get_task_calls = 0 + + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + if kwargs.get("task_id") is not None: + return super().stream_message_parts(_url, _parts, context_id=context_id, **kwargs) + self.stream_contexts.append(context_id) + self.stream_options.append(kwargs) + + async def events(): + yield _event(context_id=context_id) + event = _event(context_id=context_id) + permission = _permission_event(context_id=context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + yield event + + return events() + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + self.get_task_calls += 1 + if self.get_task_calls <= 2: + event = _event(context_id=self.context_id) + permission = _permission_event(context_id=self.context_id)["result"]["metadata"]["iac_code"]["input"] + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": [permission]}} + return event + return _event(context_id=self.context_id, state="TASK_STATE_COMPLETED") + + def subscribe_task(self, _url, _task_id): + async def events(): + for event in subscription_events: + yield event + + return events() + + fake = CompletedBetweenGetAndSubscribeClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + initial = _payload(tmp_path) + initial["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + resume = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + resume["forwardedProps"]["iacCode"]["runMode"] = "pipeline" + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=initial) + fake.context_id = adapter._threads["thread-1"].context_id + response = await client.post("/", json=resume) + + assert _events(response)[-1]["outcome"] == {"type": "success"} + assert fake.get_task_calls == 3 + + +@pytest.mark.asyncio +async def test_question_selection_resume_is_sent_to_same_a2a_task(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient( + input_value={ + "schemaVersion": 1, + "kind": "ask_user_question", + "requestTaskId": "task-1", + "contextId": "unused-by-adapter", + "inputId": "question-1", + "toolUseId": "ask-1", + "prompt": "Choose a plan", + "options": [ + {"id": "plan-a", "label": "Plan A"}, + {"id": "plan-b", "label": "Plan B"}, + ], + "allowFreeText": True, + "required": True, + } + ) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/", json=_payload(tmp_path)) + second = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "question-1", + "status": "resolved", + "payload": {"selectedId": "plan-b"}, + } + ], + ), + ) + + assert _events(first)[-1]["outcome"]["type"] == "interrupt" + assert fake.resumed_prompts == [("Plan B", "task-1")] + second_events = _events(second) + assert sum( + event.get("type") == "TOOL_CALL_RESULT" and event.get("toolCallId") == "ask-1" + for event in second_events + ) == 1 + assert second_events[-1]["outcome"] == {"type": "success"} + + +@pytest.mark.asyncio +async def test_question_resume_failure_keeps_answer_pending_for_retry(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + class RetryableQuestionClient(FakeA2AClient): + fail_resume = True + + def stream_message(self, url, prompt, *, context_id, task_id=None, **kwargs): + if not self.fail_resume: + return super().stream_message(url, prompt, context_id=context_id, task_id=task_id, **kwargs) + + async def events(): + raise RuntimeError("injected failure before the first A2A event") + yield {} + + return events() + + fake = RetryableQuestionClient( + input_value={ + "schemaVersion": 1, + "kind": "ask_user_question", + "requestTaskId": "task-1", + "contextId": "unused-by-adapter", + "inputId": "question-1", + "toolUseId": "ask-1", + "prompt": "Choose a plan", + "options": [{"id": "plan-a", "label": "Plan A"}], + "allowFreeText": True, + "required": True, + } + ) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + response = { + "interruptId": "question-1", + "status": "resolved", + "payload": {"selectedId": "plan-a"}, + } + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + await client.post("/", json=_payload(tmp_path)) + fake.context_id = adapter._threads["thread-1"].context_id + failed = await client.post("/", json=_payload(tmp_path, run_id="run-failed", resume=[response])) + assert [event["type"] for event in _events(failed)] == ["RUN_STARTED", "RUN_ERROR"] + assert set(adapter._threads["thread-1"].pending) == {"question-1"} + + fake.fail_resume = False + retried = await client.post("/", json=_payload(tmp_path, run_id="run-retried", resume=[response])) + + assert adapter._threads["thread-1"].pending == {} + assert _events(retried)[-1]["outcome"] == {"type": "success"} + + +@pytest.mark.asyncio +async def test_cancel_extension_forwards_to_a2a_task(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + response = await client.post( + "/extensions/iac-code/v1/executions/{}/cancel".format(session["value"]["executionId"]), + json={"threadId": "thread-1", "rosInvocationId": "invocation-1"}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + assert fake.cancelled == ["task-1"] + + +@pytest.mark.asyncio +async def test_ordinary_new_turn_reuses_a2a_context_but_rotates_execution(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/", json=_payload(tmp_path)) + second = await client.post("/", json=_payload(tmp_path, run_id="run-2")) + + first_session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + second_session = next(event for event in _events(second) if event.get("name") == "iac-code.session.v1") + assert fake.stream_contexts[0] == fake.stream_contexts[1] + assert first_session["value"]["contextId"] == second_session["value"]["contextId"] + assert first_session["value"]["executionId"] != second_session["value"]["executionId"] + + +@pytest.mark.asyncio +async def test_request_runtime_overrides_are_forwarded_only_as_a2a_metadata(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + fake = FakeA2AClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake) + app = create_app(adapter=adapter) + payload = _payload(tmp_path) + payload["forwardedProps"]["iacCode"].update( + { + "model": "qwen-test", + "llmApiKey": "fake-provider-key", + "thinking": {"enabled": True, "effort": "low", "budget": 1024}, + "alibabaCloud": { + "accessKeyId": "fake-access-key", + "accessKeySecret": "fake-access-secret", + "securityToken": "fake-sts-token", + "regionId": "cn-hangzhou", + }, + } + ) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/", json=payload) + + assert response.status_code == 200 + options = fake.stream_options[0] + assert options["model"] == "qwen-test" + assert options["iac_code_api_key"] == "fake-provider-key" + assert options["thinking_enabled"] is True + assert options["thinking_effort"] == "low" + assert options["thinking_budget"] == 1024 + assert options["iac_code_metadata"] == { + "cleanupOnly": False, + "rosInvocationId": "invocation-1", + "preferredLanguage": "en", + "alibaba_cloud_access_key_id": "fake-access-key", + "alibaba_cloud_access_key_secret": "fake-access-secret", + "alibaba_cloud_security_token": "fake-sts-token", + "alibaba_cloud_region_id": "cn-hangzhou", + } + + +@pytest.mark.asyncio +async def test_heartbeat_remains_sse_comment_and_not_agui_event(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setattr("iac_code.agui.app._HEARTBEAT_SECONDS", 0.01) + + class SlowA2AClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + del kwargs + + async def events(): + await asyncio.sleep(0.035) + yield _event(context_id=context_id) + yield _event(context_id=context_id, state="TASK_STATE_INPUT_REQUIRED") + + return events() + + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=SlowA2AClient()) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=adapter)), base_url="http://test" + ) as client: + response = await client.post("/", json=_payload(tmp_path)) + + assert ": heartbeat\n\n" in response.text + assert all(event.get("object") != "heartbeat" for event in _events(response)) + + +def test_mapper_consumes_real_local_a2a_wire_contract(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(tmp_path)) + runtime = FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="from-real-a2a-wire")]), + session_id="session-1", + ) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda _options: runtime) + a2a_app = create_a2a_app(host="127.0.0.1", port=41242, token=None, model="qwen-test") + + with TestClient(a2a_app) as client: + with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "request-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "message-1", + "contextId": "context-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + raw_events = [ + json.loads(line.removeprefix("data: ")) + for line in response.iter_lines() + if line.startswith("data: ") + ] + + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + mapped = [mapped_event for event in raw_events for mapped_event in mapper.map(event)] + assert response.status_code == 200 + assert any(getattr(event, "delta", None) == "from-real-a2a-wire" for event in mapped), raw_events + assert a2a_state(raw_events[-1]) == "input-required" + + +@pytest.mark.asyncio +async def test_http_errors_use_payload_or_accept_language(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setattr( + "iac_code.agui.app.translate_message", + lambda message, *, language: f"{language}:{message}", + ) + app = create_app(adapter=AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient()), auth_token="secret") + payload = _payload(tmp_path) + payload["forwardedProps"]["iacCode"]["preferredLanguage"] = "zh-CN" + payload.pop("threadId") + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + unauthorized = await client.post( + "/", + headers={"Accept-Language": "ja-JP, en;q=0.5"}, + json={}, + ) + weighted = await client.post( + "/", + headers={"Accept-Language": "zh;q=0, en;q=0.2, ja;q=1"}, + json={}, + ) + invalid = await client.post( + "/", + headers={"Authorization": "Bearer secret"}, + json=payload, + ) + + assert unauthorized.json()["error"]["message"] == "ja:A valid bearer token is required." + assert weighted.json()["error"]["message"] == "ja:A valid bearer token is required." + assert invalid.json()["error"]["message"] == "zh:Invalid AG-UI RunAgentInput envelope." + + +@pytest.mark.asyncio +async def test_run_errors_use_request_language_without_global_locale(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setattr( + "iac_code.agui.errors.translate_message", + lambda message, *, language: f"{language}:{message}", + ) + + class FailingA2AClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + del context_id, kwargs + + async def events(): + raise RuntimeError("injected failure") + yield {} + + return events() + + payload = _payload(tmp_path) + payload["forwardedProps"]["iacCode"]["preferredLanguage"] = "zh-CN" + app = create_app(adapter=AguiA2AAdapter(a2a_url="http://a2a/", client=FailingA2AClient())) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/", json=payload) + + assert _events(response)[-1]["message"] == "zh:The local A2A execution service is unavailable." + + +@pytest.mark.asyncio +async def test_accept_language_reaches_stream_errors_and_a2a_metadata(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setattr( + "iac_code.agui.errors.translate_message", + lambda message, *, language: f"{language}:{message}", + ) + + class FailingA2AClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + self.stream_contexts.append(context_id) + self.stream_options.append(kwargs) + + async def events(): + raise RuntimeError("injected failure") + yield {} + + return events() + + fake = FailingA2AClient() + payload = _payload(tmp_path) + app = create_app(adapter=AguiA2AAdapter(a2a_url="http://a2a/", client=fake)) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/", + headers={"Accept-Language": "en;q=0.2, zh-CN;q=1"}, + json=payload, + ) + + assert _events(response)[-1]["message"] == "zh:The local A2A execution service is unavailable." + assert fake.stream_options[0]["iac_code_metadata"]["preferredLanguage"] == "zh" + + +@pytest.mark.asyncio +async def test_idle_monitor_requests_server_shutdown_without_killing_process() -> None: + from iac_code.agui.app import _monitor_idle + + shutdown_requested: list[bool] = [] + adapter = FakeA2AClient() + adapter.is_idle = True + adapter.last_activity = asyncio.get_running_loop().time() - 10 + + await _monitor_idle(adapter, 0.01, lambda: shutdown_requested.append(True)) + + assert shutdown_requested == [True] diff --git a/tests/agui/test_events.py b/tests/agui/test_events.py new file mode 100644 index 00000000..1a577062 --- /dev/null +++ b/tests/agui/test_events.py @@ -0,0 +1,855 @@ +from __future__ import annotations + +import json + +from ag_ui.core import EventType + +from iac_code.agui.events import ( + A2AEventMapper, + a2a_iac_code_session_id, + a2a_inputs, + a2a_sideband_input_ids, + interrupt_from_a2a, +) + + +def test_reasoning_is_mapped_from_a2a_event_without_a_second_agui_gate() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + + mapped = mapper.map( + {"result": {"metadata": {"iac_code": {"thinking": {"type": "raw_thinking", "text": "reasoning"}}}}} + ) + + assert [event.type for event in mapped] == [ + EventType.REASONING_START, + EventType.REASONING_MESSAGE_START, + EventType.REASONING_MESSAGE_CONTENT, + ] + assert mapped[-1].delta == "reasoning" + + +def test_a2a_usage_accepts_integer_valued_protobuf_struct_numbers() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "usage": { + "inputTokens": 6497.0, + "outputTokens": 26.0, + "totalTokens": 6523.0, + "cachedInputTokens": 0.0, + } + } + } + } + } + ) + + assert len(mapper.usage) == 1 + assert mapper.usage[0].input_tokens == 6497 + assert mapper.usage[0].output_tokens == 26 + assert mapper.usage[0].total_tokens == 6523 + assert mapper.usage[0].cached_input_tokens == 0 + + +def test_pipeline_usage_envelope_is_aggregated_for_run_usage() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + + mapped = mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": "usage-1", + "eventType": "usage", + "data": { + "provider": "dashscope", + "model": "qwen-test", + "inputTokens": 12, + "outputTokens": 3, + "totalTokens": 15, + "cachedInputTokens": 2, + }, + } + } + } + } + } + ) + + assert len(mapper.usage) == 1 + assert mapper.usage[0].provider == "dashscope" + assert mapper.usage[0].model == "qwen-test" + assert mapper.usage[0].total_tokens == 15 + assert all(event.type != EventType.CUSTOM for event in mapped) + + +def test_pipeline_custom_projection_suppresses_standard_and_internal_events() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + envelopes = [ + {"eventType": "message_started", "data": {"messageId": "message-1"}}, + {"eventType": "context_usage", "data": {"currentTokens": 10}}, + { + "eventType": "tool_started", + "data": {"toolUseId": "tool-1", "toolName": "bash", "input": {"cmd": "pwd"}}, + }, + { + "eventType": "tool_result", + "data": {"toolUseId": "tool-1", "toolName": "bash", "result": "ok"}, + }, + {"eventType": "step_started", "step": {"id": "prepare"}}, + {"eventType": "step_completed", "step": {"id": "prepare"}}, + {"eventType": "permission_requested", "data": {"toolUseId": "tool-2"}}, + {"eventType": "backup_committed", "data": {"committedSequence": 1}}, + ] + + mapped = [] + for sequence, envelope in enumerate(envelopes, start=1): + envelope.update({"eventId": f"event-{sequence}", "sequence": sequence}) + mapped.extend(mapper.map({"result": {"metadata": {"iac_code": {"pipeline": envelope}}}})) + + assert all(event.type != EventType.CUSTOM for event in mapped) + assert EventType.TOOL_CALL_START in {event.type for event in mapped} + assert EventType.TOOL_CALL_RESULT in {event.type for event in mapped} + assert EventType.STEP_STARTED in {event.type for event in mapped} + assert EventType.STEP_FINISHED in {event.type for event in mapped} + + +def test_pipeline_custom_projection_keeps_ui_semantic_extensions() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + event_types = [ + "pipeline_started", + "pipeline_completed", + "candidate_started", + "candidate_detail_shown", + "stack_progress", + "pipeline_warning", + "candidate_step_failed", + ] + + mapped = [] + for sequence, event_type in enumerate(event_types, start=1): + envelope = { + "eventId": f"event-{sequence}", + "eventType": event_type, + "sequence": sequence, + "data": {"summary": event_type}, + } + mapped.extend(mapper.map({"result": {"metadata": {"iac_code": {"pipeline": envelope}}}})) + + assert [event.value["eventType"] for event in mapped if event.type == EventType.CUSTOM] == event_types + + +def test_iac_code_session_id_is_read_from_pipeline_batch_envelopes() -> None: + assert ( + a2a_iac_code_session_id( + { + "result": { + "metadata": { + "iac_code": { + "pipelineBatch": { + "events": [{"eventType": "step_started", "iacCodeSessionId": "session-1"}] + } + } + } + } + } + ) + == "session-1" + ) + + +def test_a2a_tool_metadata_maps_to_standard_tool_events() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + started = mapper.map( + { + "result": { + "taskId": "task-1", + "contextId": "context-1", + "status": {"state": "TASK_STATE_WORKING"}, + "metadata": {"iac_code": {"tool": {"status": "started", "toolUseId": "tool-1", "name": "bash"}}}, + } + } + ) + completed = mapper.map( + { + "result": { + "taskId": "task-1", + "contextId": "context-1", + "status": {"state": "TASK_STATE_WORKING"}, + "metadata": { + "iac_code": { + "tool": { + "status": "input_complete", + "toolUseId": "tool-1", + "name": "bash", + "toolInput": {"command": "pwd", "token": "[REDACTED]"}, + } + } + }, + } + } + ) + + assert started[0].type == EventType.TOOL_CALL_START + assert [event.type for event in completed] == [EventType.TOOL_CALL_ARGS, EventType.TOOL_CALL_END] + assert json.loads(completed[0].delta) == {"command": "pwd", "token": "[REDACTED]"} + + +def test_permission_metadata_becomes_self_describing_standard_interrupt() -> None: + interrupt = interrupt_from_a2a( + { + "schemaVersion": 1, + "kind": "permission", + "inputId": "permission-1", + "toolUseId": "tool-1", + "toolName": "aliyun_api", + "title": "Create ROS stack", + "purpose": "Create the requested infrastructure.", + "effect": "cloud_change", + "target": "ROS CreateStack in cn-hangzhou", + "prompt": "Create ROS stack. Allow once?", + "safeSummary": "Create stack demo", + "options": [{"id": "allow_once", "label": "Allow once"}, {"id": "deny", "label": "Deny"}], + "required": True, + }, + ttl_seconds=60, + ) + + assert interrupt.id == "permission-1" + assert interrupt.message == "Create ROS stack. Allow once?" + assert interrupt.response_schema["properties"]["decision"]["enum"] == ["allow_once", "deny"] + assert interrupt.metadata["purpose"] == "Create the requested infrastructure." + + +def test_interrupt_fallback_messages_use_projection_language(monkeypatch) -> None: + monkeypatch.setattr( + "iac_code.agui.events.translate_message", + lambda message, *, language: f"{language}:{message}", + ) + + permission = interrupt_from_a2a( + {"kind": "permission", "inputId": "permission-1", "language": "zh-CN"}, + ttl_seconds=60, + ) + question = interrupt_from_a2a( + {"kind": "ask_user_question", "inputId": "question-1", "language": "ja-JP"}, + ttl_seconds=60, + ) + + assert permission.message == "zh:Permission required" + assert question.message == "ja:Input required" + + +def test_interleaved_pipeline_tools_and_steps_remain_balanced() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + envelopes = [ + {"eventId": "step-a-start", "eventType": "step_started", "step": {"id": "step-a"}}, + {"eventId": "step-b-start", "eventType": "step_started", "step": {"id": "step-b"}}, + {"eventId": "step-b-end", "eventType": "step_completed", "step": {"id": "step-b"}}, + {"eventId": "step-a-end", "eventType": "step_completed", "step": {"id": "step-a"}}, + ] + + mapped = [] + for envelope in envelopes: + mapped.extend( + mapper.map( + { + "result": { + "metadata": {"iac_code": {"pipeline": envelope}}, + } + } + ) + ) + + assert [event.step_name for event in mapped if event.type == EventType.STEP_STARTED] == ["step-a", "step-b"] + assert [event.step_name for event in mapped if event.type == EventType.STEP_FINISHED] == ["step-b", "step-a"] + snapshots = [event for event in mapped if event.type == EventType.ACTIVITY_SNAPSHOT] + assert snapshots == [] + + +def test_close_all_finishes_run_steps_without_losing_durable_pipeline_state() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + mapped = mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipelineBatch": { + "events": [ + { + "eventId": "parent-start", + "eventType": "step_started", + "step": {"id": "evaluate_candidates"}, + }, + { + "eventId": "candidate-start", + "eventType": "candidate_step_started", + "candidate": {"runId": "candidate-0"}, + "candidateStep": {"id": "template_generating"}, + }, + ] + } + } + } + } + } + ) + + closing = mapper.close_all() + + assert [event.step_name for event in mapped if event.type == EventType.STEP_STARTED] == [ + "evaluate_candidates", + "candidate:candidate-0:template_generating", + ] + assert [event.step_name for event in closing if event.type == EventType.STEP_FINISHED] == [ + "candidate:candidate-0:template_generating", + "evaluate_candidates", + ] + assert mapper.open_pipeline_steps == { + "step:evaluate_candidates", + "candidate:candidate-0:template_generating", + } + assert mapper.close_all() == [] + + +def test_resume_reopens_durable_pipeline_steps_and_finishes_them_in_the_new_run() -> None: + mapper = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + open_pipeline_steps={ + "step:evaluate_candidates", + "candidate:candidate-0:template_generating", + }, + ) + + reopened = mapper.reopen_pipeline_steps() + completed = mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": "candidate-complete", + "eventType": "candidate_step_completed", + "candidate": {"runId": "candidate-0"}, + "candidateStep": {"id": "template_generating"}, + } + } + } + } + } + ) + closing = mapper.close_all() + + assert [event.step_name for event in reopened] == [ + "candidate:candidate-0:template_generating", + "evaluate_candidates", + ] + assert [event.step_name for event in completed if event.type == EventType.STEP_FINISHED] == [ + "candidate:candidate-0:template_generating" + ] + assert [event.step_name for event in closing if event.type == EventType.STEP_FINISHED] == ["evaluate_candidates"] + assert mapper.open_pipeline_steps == {"step:evaluate_candidates"} + + +def test_parallel_candidate_steps_use_unique_agui_step_names() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + mapped = [] + for candidate in ("candidate-0", "candidate-1"): + mapped.extend( + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": f"{candidate}-start", + "eventType": "candidate_step_started", + "candidate": {"runId": candidate}, + "candidateStep": {"id": "template_generating"}, + } + } + } + } + } + ) + ) + + names = [event.step_name for event in mapped if event.type == EventType.STEP_STARTED] + assert names == [ + "candidate:candidate-0:template_generating", + "candidate:candidate-1:template_generating", + ] + assert len(names) == len(set(names)) + + +def test_interleaved_pipeline_text_keeps_one_message_span_per_candidate() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + candidates = ("candidate-a", "candidate-b", "candidate-a", "candidate-b") + + mapped = [] + for sequence, candidate in enumerate(candidates, start=1): + mapped.extend( + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": f"text-{sequence}", + "eventType": "text_delta", + "sequence": sequence, + "scope": "candidate_step", + "candidateStep": {"runId": candidate}, + "data": {"text": f"delta-{sequence}"}, + } + } + } + } + } + ) + ) + mapped.extend(mapper.close_all()) + + starts = [event.message_id for event in mapped if event.type == EventType.TEXT_MESSAGE_START] + contents = [event.message_id for event in mapped if event.type == EventType.TEXT_MESSAGE_CONTENT] + ends = [event.message_id for event in mapped if event.type == EventType.TEXT_MESSAGE_END] + assert len(starts) == 2 + assert len(contents) == 4 + assert sorted(starts) == sorted(ends) + assert all(event.type != EventType.CUSTOM for event in mapped) + + +def test_parallel_candidate_text_uses_candidate_run_identity_before_shared_step_id() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + mapped = [] + for sequence, candidate in enumerate(("candidate-a", "candidate-b"), start=1): + mapped.extend( + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": f"text-{sequence}", + "eventType": "text_delta", + "sequence": sequence, + "scope": "candidate_step", + "candidate": {"runId": candidate}, + "candidateStep": {"id": "template_generating"}, + "data": {"text": candidate}, + } + } + } + } + } + ) + ) + + message_ids = [event.message_id for event in mapped if event.type == EventType.TEXT_MESSAGE_START] + assert len(message_ids) == 2 + assert len(set(message_ids)) == 2 + assert "candidate-a:template_generating" in message_ids[0] + assert "candidate-b:template_generating" in message_ids[1] + + +def test_pipeline_thinking_maps_to_standard_reasoning_without_custom_event() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + mapped = [] + for sequence, candidate in enumerate(("candidate-a", "candidate-b"), start=1): + mapped.extend( + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": f"thinking-{sequence}", + "eventType": "thinking_delta", + "sequence": sequence, + "scope": "candidate_step", + "candidate": {"runId": candidate}, + "candidateStep": {"id": "template_generating"}, + "data": {"type": "raw_thinking", "text": candidate}, + } + } + } + } + } + ) + ) + + contents = [event for event in mapped if event.type == EventType.REASONING_MESSAGE_CONTENT] + assert [event.delta for event in contents] == ["candidate-a", "candidate-b"] + assert len({event.message_id for event in contents}) == 2 + assert all(event.type != EventType.CUSTOM for event in mapped) + + +def test_input_received_reopens_a_step_closed_for_interactive_selection() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + envelopes = [ + {"eventId": "start", "eventType": "step_started", "step": {"id": "confirm_and_select"}}, + {"eventId": "pause", "eventType": "step_completed", "step": {"id": "confirm_and_select"}}, + {"eventId": "answer", "eventType": "input_received", "step": {"id": "confirm_and_select"}}, + {"eventId": "done", "eventType": "step_completed", "step": {"id": "confirm_and_select"}}, + ] + + mapped = [] + for envelope in envelopes: + mapped.extend(mapper.map({"result": {"metadata": {"iac_code": {"pipeline": envelope}}}})) + + assert sum(event.type == EventType.STEP_STARTED for event in mapped) == 2 + assert sum(event.type == EventType.STEP_FINISHED for event in mapped) == 2 + + +def test_unknown_pipeline_events_are_not_forwarded_as_custom() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-1") + + mapped = [] + for sequence in range(500): + mapped.extend( + mapper.map( + { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": f"progress-{sequence}", + "eventType": "pipeline_progress", + "sequence": sequence, + } + } + } + } + } + ) + ) + + assert mapped == [] + + +def test_repeated_pipeline_batch_event_id_is_mapped_once() -> None: + mapper = A2AEventMapper( + thread_id="thread-1", + run_id="run-1", + open_pipeline_steps={"step:candidate-a"}, + ) + payload = { + "result": { + "metadata": { + "iac_code": { + "pipelineBatch": { + "events": [ + { + "eventId": "step-1", + "eventType": "candidate_step_completed", + "candidateStep": {"id": "candidate-a"}, + } + ] + } + } + } + } + } + + mapped = [*mapper.map(payload), *mapper.map(payload)] + + assert sum(event.type == EventType.STEP_FINISHED for event in mapped) == 1 + assert sum(event.type == EventType.CUSTOM for event in mapped) == 0 + + +def test_task_snapshot_exposes_all_pending_pipeline_permissions_once() -> None: + first = {"kind": "permission", "inputId": "permission-1", "required": True} + second = {"kind": "permission", "inputId": "permission-2", "required": True} + event = { + "result": { + "metadata": { + "iac_code": { + "input": first, + "pendingPermissions": [first, second], + } + } + } + } + + assert [value["inputId"] for value in a2a_inputs(event)] == ["permission-1", "permission-2"] + assert a2a_sideband_input_ids(event) == {"permission-1", "permission-2"} + + +def test_direct_candidate_permission_is_recognized_as_sideband() -> None: + event = { + "result": { + "metadata": { + "iac_code": { + "input": { + "kind": "permission", + "inputId": "permission-1", + "scope": "candidate", + "subPipelineId": "candidate-a", + "required": True, + } + } + } + } + } + + assert a2a_sideband_input_ids(event) == {"permission-1"} + + +def test_pipeline_recovery_emits_one_replace_snapshot_and_deduplicated_steps() -> None: + mapper = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + open_pipeline_steps={"step:candidate-b"}, + ) + event = { + "eventId": "candidate-b-complete", + "eventType": "candidate_step_completed", + "sequence": 8, + "candidateStep": {"id": "candidate-b"}, + } + + mapped = mapper.map_pipeline_recovery( + { + "snapshot": { + "schemaVersion": "1.0", + "pipelineRunId": "pipeline-1", + "lastSequence": 8, + "display": {"messages": [{"text": "candidate B continued"}]}, + }, + "events": [event, event], + } + ) + + snapshots = [item for item in mapped if item.type == EventType.ACTIVITY_SNAPSHOT] + finished = [item for item in mapped if item.type == EventType.STEP_FINISHED] + customs = [item for item in mapped if item.type == EventType.CUSTOM] + assert len(snapshots) == 1 + assert snapshots[0].replace is True + assert len(finished) == 1 + assert finished[0].step_name == "candidate-b" + assert len(customs) == 0 + assert mapper.last_pipeline_sequence == 8 + + +def test_pipeline_recovery_maps_post_disconnect_text_and_tool_to_standard_events() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-2") + + mapped = mapper.map_pipeline_recovery( + { + "snapshot": {"schemaVersion": "1.0", "pipelineRunId": "pipeline-1", "lastSequence": 4}, + "events": [ + { + "eventId": "candidate-text", + "eventType": "text_delta", + "sequence": 2, + "scope": "candidate", + "candidate": {"runId": "candidate-b"}, + "data": {"text": "candidate B completed"}, + }, + { + "eventId": "tool-started", + "eventType": "tool_started", + "sequence": 3, + "data": {"toolUseId": "tool-1", "toolName": "bash", "input": {"cmd": "pwd"}}, + }, + { + "eventId": "tool-result", + "eventType": "tool_result", + "sequence": 4, + "data": {"toolUseId": "tool-1", "toolName": "bash", "result": "ok", "isError": False}, + }, + ], + } + ) + + event_types = [event.type for event in mapped] + assert EventType.TEXT_MESSAGE_CONTENT in event_types + assert EventType.TOOL_CALL_START in event_types + assert EventType.TOOL_CALL_ARGS in event_types + assert EventType.TOOL_CALL_END in event_types + assert EventType.TOOL_CALL_RESULT in event_types + assert all(event.type != EventType.CUSTOM for event in mapped) + + +def test_pipeline_recovery_maps_thinking_without_replaying_custom_event() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-2") + + mapped = mapper.map_pipeline_recovery( + { + "snapshot": {"pipelineRunId": "pipeline-1", "lastSequence": 1}, + "events": [ + { + "eventId": "thinking-1", + "eventType": "thinking_delta", + "sequence": 1, + "data": {"type": "raw_thinking", "text": "recovered reasoning"}, + } + ], + } + ) + + assert [event.delta for event in mapped if event.type == EventType.REASONING_MESSAGE_CONTENT] == [ + "recovered reasoning" + ] + assert all( + not (event.type == EventType.CUSTOM and event.value.get("eventType") == "thinking_delta") for event in mapped + ) + + +def test_mapper_suppresses_task_pipeline_suffix_before_authoritative_recovery() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-2") + payload = { + "result": { + "metadata": { + "iac_code": { + "pipeline": { + "eventId": "old-step-finished", + "eventType": "step_completed", + "sequence": 7, + "step": {"id": "confirm_and_select"}, + } + } + } + } + } + + assert mapper.map(payload, include_pipeline=False) == [] + assert mapper.last_pipeline_sequence == 0 + + +def test_mapper_deduplicates_tool_results_from_snapshot_and_live_stream() -> None: + mapper = A2AEventMapper(thread_id="thread-1", run_id="run-2") + payload = { + "result": { + "metadata": { + "iac_code": { + "tool": { + "status": "completed", + "toolUseId": "tool-1", + "name": "aliyun_api", + "result": "Permission denied.", + } + } + } + } + } + + mapped = [*mapper.map(payload), *mapper.map(payload)] + + assert sum(event.type == EventType.TOOL_CALL_RESULT for event in mapped) == 1 + + +def test_mapper_emits_only_new_suffix_from_cumulative_resume_text() -> None: + def status_text(text: str) -> dict[str, object]: + return { + "result": { + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "messageId": "assistant-1", + "role": "ROLE_AGENT", + "parts": [{"text": text}], + }, + } + } + } + + first = A2AEventMapper(thread_id="thread-1", run_id="run-1") + first.map(status_text("first")) + first.finalize_text_snapshots() + + second = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + text_snapshot_digests=first.text_snapshot_digests, + ) + second_events = second.map(status_text("firstsecond")) + second.finalize_text_snapshots() + + third = A2AEventMapper( + thread_id="thread-1", + run_id="run-3", + text_snapshot_digests=second.text_snapshot_digests, + ) + third_events = third.map(status_text("firstsecondthird")) + + assert [event.delta for event in second_events if event.type == EventType.TEXT_MESSAGE_CONTENT] == ["second"] + assert [event.delta for event in third_events if event.type == EventType.TEXT_MESSAGE_CONTENT] == ["third"] + + +def test_mapper_keeps_unmatched_resume_text_and_tracks_exact_replay_for_later_suffix() -> None: + def status_text(text: str) -> dict[str, object]: + return { + "result": { + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "messageId": "assistant-1", + "role": "ROLE_AGENT", + "parts": [{"text": text}], + }, + } + } + } + + prior_text = "before interrupt" + prior = A2AEventMapper(thread_id="thread-1", run_id="run-1") + prior.map(status_text(prior_text)) + prior.finalize_text_snapshots() + + replay = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + text_snapshot_digests=prior.text_snapshot_digests, + ) + exact_replay = status_text(prior_text) + exact_replay["result"]["metadata"] = {"iac_code": {"assistantFinal": {"complete": True}}} + exact_events = replay.map(exact_replay) + suffix_events = replay.map(status_text(" after resume")) + replay.finalize_text_snapshots() + + unrelated = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + text_snapshot_digests=prior.text_snapshot_digests, + ) + unrelated_events = unrelated.map(status_text("different response")) + + assert exact_events == [] + assert [event.delta for event in suffix_events if event.type == EventType.TEXT_MESSAGE_CONTENT] == [" after resume"] + assert [event.delta for event in unrelated_events if event.type == EventType.TEXT_MESSAGE_CONTENT] == [ + "different response" + ] + assert replay.text_snapshot_digests != prior.text_snapshot_digests + + +def test_authoritative_recovery_can_fill_unseen_event_below_live_cursor() -> None: + mapper = A2AEventMapper( + thread_id="thread-1", + run_id="run-2", + open_pipeline_steps={"step:candidate-a"}, + ) + mapper.last_pipeline_sequence = 10 + + mapped = mapper.map_pipeline_recovery( + { + "snapshot": {"pipelineRunId": "pipeline-1", "lastSequence": 12}, + "events": [ + { + "eventId": "missed-step-9", + "eventType": "candidate_step_completed", + "sequence": 9, + "candidateStep": {"id": "candidate-a"}, + } + ], + } + ) + + assert sum(event.type == EventType.STEP_FINISHED for event in mapped) == 1 + assert mapper.last_pipeline_sequence == 12 diff --git a/tests/agui/test_http_sse_integration.py b/tests/agui/test_http_sse_integration.py new file mode 100644 index 00000000..0211ca2e --- /dev/null +++ b/tests/agui/test_http_sse_integration.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import asyncio +import json +import socket +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Iterator + +import httpx +import pytest +import uvicorn + +from iac_code.a2a.app import create_app as create_a2a_app +from iac_code.a2a.executor import IacCodeA2AExecutor +from iac_code.agui.app import create_app as create_agui_app +from iac_code.pipeline.engine.events import PipelineEvent, PipelineEventType +from iac_code.types.stream_events import PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent + + +class _ToolRegistry: + def register(self, tool) -> None: + del tool + + def unregister(self, tool_name: str) -> None: + del tool_name + + +class _ParallelPermissionPipeline: + pipeline_name = "selling" + sidecar_status = None + handoff_enabled = False + + def __init__(self, session_dir: Path) -> None: + self.session = SimpleNamespace(session_dir=session_dir) + self.other_candidate_continued = threading.Event() + self.permission_resolved = threading.Event() + self.permission_future: asyncio.Future[bool] | None = None + + async def run(self, prompt: str): + del prompt + yield PipelineEvent( + type=PipelineEventType.PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={"total_steps": 2, "step_names": ["candidate-a", "candidate-b"]}, + ) + self.permission_future = asyncio.get_running_loop().create_future() + yield SubPipelineStreamEvent( + sub_pipeline_id="candidate-a", + candidate_index=0, + inner=PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-a", + response_future=self.permission_future, + ), + ) + yield SubPipelineStreamEvent( + sub_pipeline_id="candidate-b", + candidate_index=1, + inner=TextDeltaEvent(text="candidate B continued while A waited"), + ) + self.other_candidate_continued.set() + approved = await self.permission_future + self.permission_resolved.set() + yield SubPipelineStreamEvent( + sub_pipeline_id="candidate-a", + candidate_index=0, + inner=TextDeltaEvent(text=f"candidate A resumed: {approved}"), + ) + yield PipelineEvent( + type=PipelineEventType.PIPELINE_COMPLETED, + step_id=None, + timestamp=time.time(), + data={"total_steps": 2}, + ) + + def clear_sidecar(self) -> None: + self.sidecar_status = None + + def should_switch_to_normal(self, data: dict[str, Any]) -> bool: + del data + return False + + +@contextmanager +def _serve(app: Any) -> Iterator[str]: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + server = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error", access_log=False, lifespan="on") + ) + thread = threading.Thread(target=server.run, name=f"test-uvicorn-{port}", daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not server.started and thread.is_alive() and time.monotonic() < deadline: + time.sleep(0.01) + if not server.started: + server.should_exit = True + thread.join(timeout=5) + raise RuntimeError("test Uvicorn server did not start") + try: + yield f"http://127.0.0.1:{port}/" + finally: + server.should_exit = True + thread.join(timeout=10) + if thread.is_alive(): + server.force_exit = True + thread.join(timeout=5) + + +def _read_agui(url: str, payload: dict[str, Any]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + with httpx.Client(timeout=20) as client: + with client.stream("POST", url, json=payload, headers={"Accept": "text/event-stream"}) as response: + response.raise_for_status() + for line in response.iter_lines(): + if line.startswith("data: "): + events.append(json.loads(line.removeprefix("data: "))) + return events + + +def _run_payload(workspace: Path, *, run_id: str, resume: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return { + "threadId": "thread-http-sse", + "runId": run_id, + "state": {}, + "messages": [] if resume else [{"id": "message-1", "role": "user", "content": "run pipeline"}], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "ros-invocation-http-sse", + "cwd": str(workspace), + } + }, + **({"resume": resume} if resume is not None else {}), + } + + +def _get_task(a2a_url: str, task_id: str) -> dict[str, Any]: + response = httpx.post( + a2a_url, + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "get-task", "method": "GetTask", "params": {"id": task_id}}, + timeout=10, + ) + response.raise_for_status() + return response.json()["result"] + + +def test_real_http_sse_sub_pipeline_interrupt_survives_agui_restart_and_snapshot_catches_up( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + state_dir = tmp_path / "migratable-agui-state" + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(workspace)) + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(workspace)) + monkeypatch.setattr("iac_code.a2a.input_required.emit_permission_boundary_audit", lambda *_a, **_k: True) + monkeypatch.setattr("iac_code.a2a.pipeline_stream.emit_permission_boundary_audit", lambda *_a, **_k: True) + + pipeline = _ParallelPermissionPipeline(tmp_path / "pipeline-sidecar") + runtime = SimpleNamespace(provider_manager=object(), tool_registry=_ToolRegistry()) + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_agent_runtime", lambda _options: runtime) + + def create_test_pipeline(*_args, **kwargs): + pipeline.session.session_dir = ( + kwargs["session_storage"].session_dir( + kwargs["cwd"], + kwargs["session_id"], + ) + / "pipeline" + ) + return pipeline + + monkeypatch.setattr("iac_code.a2a.pipeline_executor.create_pipeline", create_test_pipeline) + cancel_calls: list[str | None] = [] + original_cancel = IacCodeA2AExecutor.cancel + + async def record_cancel(self, context, event_queue): + cancel_calls.append(context.task_id) + await original_cancel(self, context, event_queue) + + monkeypatch.setattr(IacCodeA2AExecutor, "cancel", record_cancel) + a2a_app = create_a2a_app( + host="127.0.0.1", + port=0, + token=None, + model="deterministic-test-model", + persistence_dir=tmp_path / "a2a-state", + ) + + with _serve(a2a_app) as a2a_url: + first_agui = create_agui_app(a2a_url=a2a_url, state_dir=state_dir, interrupt_ttl=30) + with _serve(first_agui) as first_agui_url: + first_events = _read_agui(first_agui_url, _run_payload(workspace, run_id="run-1")) + + first_terminal = first_events[-1] + assert first_terminal["type"] == "RUN_FINISHED" + assert first_terminal["outcome"]["type"] == "interrupt" + assert len(first_terminal["outcome"]["interrupts"]) == 1 + interrupt_id = first_terminal["outcome"]["interrupts"][0]["id"] + session = next(event["value"] for event in first_events if event.get("name") == "iac-code.session.v1") + task_id = session["taskId"] + + assert pipeline.other_candidate_continued.wait(timeout=5) + task_during_disconnect = _get_task(a2a_url, task_id) + assert task_during_disconnect["status"]["state"] not in { + "TASK_STATE_FAILED", + "TASK_STATE_CANCELED", + } + assert cancel_calls == [] + persisted = json.loads( + (state_dir / "threads" / "thread-http-sse.json").read_text(encoding="utf-8") + ) + assert persisted["execution"]["pending"][interrupt_id]["sideband"] is True + + second_agui = create_agui_app(a2a_url=a2a_url, state_dir=state_dir, interrupt_ttl=30) + with _serve(second_agui) as second_agui_url: + second_events = _read_agui( + second_agui_url, + _run_payload( + workspace, + run_id="run-2", + resume=[ + { + "interruptId": interrupt_id, + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + assert pipeline.permission_resolved.wait(timeout=5) + assert pipeline.permission_future is not None and pipeline.permission_future.result() is True + assert any(event["type"] == "ACTIVITY_SNAPSHOT" for event in second_events) + assert any( + event["type"] == "TEXT_MESSAGE_CONTENT" and "candidate B continued while A waited" in event["delta"] + for event in second_events + ) + assert "candidate B continued while A waited" in json.dumps(second_events, ensure_ascii=False) + assert second_events[-1]["type"] == "RUN_FINISHED" + assert second_events[-1]["outcome"] == {"type": "success"} + assert _get_task(a2a_url, task_id)["status"]["state"] == "TASK_STATE_COMPLETED" + assert cancel_calls == [] + assert ( + sum(event.get("outcome", {}).get("type") == "interrupt" for event in [*first_events, *second_events]) == 1 + ) diff --git a/tests/agui/test_i18n.py b/tests/agui/test_i18n.py new file mode 100644 index 00000000..56420735 --- /dev/null +++ b/tests/agui/test_i18n.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from babel.messages.pofile import read_po + +_LOCALES_ROOT = Path(__file__).parents[2] / "src" / "iac_code" / "i18n" / "locales" +_AGUI_CLI_MESSAGES = frozenset( + { + "Unable to load the AG-UI config file.", + "AG-UI config file must contain a YAML mapping.", + "--port must be between 1 and 65535.", + "--interrupt-ttl must be a positive integer.", + "--state-dir must be a string.", + "--idle-shutdown must be a non-negative number.", + "--a2a-url must be a string.", + "auth_token must be a string.", + "a2a_token must be a string.", + } +) + + +@pytest.mark.parametrize("language", ["zh", "es", "fr", "de", "ja", "pt"]) +def test_all_agui_user_messages_have_non_fuzzy_translations(language: str) -> None: + path = _LOCALES_ROOT / language / "LC_MESSAGES" / "messages.po" + with path.open("r", encoding="utf-8") as handle: + catalog = read_po(handle) + + expected = { + message.id + for message in catalog + if message.id and any(source.startswith("src/iac_code/agui/") for source, _line in message.locations) + } | _AGUI_CLI_MESSAGES + incomplete = sorted( + msgid for msgid in expected if (message := catalog.get(msgid)) is None or not message.string or message.fuzzy + ) + + assert expected + assert incomplete == [] diff --git a/tests/agui/test_inputs.py b/tests/agui/test_inputs.py new file mode 100644 index 00000000..e0c69237 --- /dev/null +++ b/tests/agui/test_inputs.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import pytest + +from iac_code.agui.errors import AguiError +from iac_code.agui.inputs import parse_forwarded_props, resolve_cwd + + +def test_forwarded_props_require_request_workspace_and_identity(tmp_path) -> None: + props = parse_forwarded_props( + { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": str(tmp_path), + "model": "qwen-test", + "runMode": "pipeline", + } + } + ) + + assert props.iac_code.cwd == str(tmp_path) + assert props.iac_code.model == "qwen-test" + assert props.iac_code.run_mode == "pipeline" + + +def test_cwd_is_checked_against_adapter_roots(tmp_path, monkeypatch) -> None: + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(allowed)) + + assert resolve_cwd(str(allowed / "session")) == str((allowed / "session").resolve()) + with pytest.raises(AguiError, match="outside the allowed roots"): + resolve_cwd(str(tmp_path / "outside")) diff --git a/tests/agui/test_persistence.py b/tests/agui/test_persistence.py new file mode 100644 index 00000000..9fc46f8b --- /dev/null +++ b/tests/agui/test_persistence.py @@ -0,0 +1,1034 @@ +from __future__ import annotations + +import asyncio +import json +import os +import stat +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx +import pytest +from ag_ui.core import EventType + +from iac_code.agui.adapter import AguiA2AAdapter +from iac_code.agui.app import create_app +from iac_code.agui.inputs import canonical_digest, parse_run_input +from iac_code.agui.state import AguiStateStoreError, FileAguiThreadStateStore +from tests.agui.test_app import FakeA2AClient, _event, _events, _payload + + +class SnapshotA2AClient(FakeA2AClient): + def __init__(self, inputs: list[dict[str, Any]]) -> None: + super().__init__(interrupt=True) + self.inputs = inputs + self.pipeline_state: dict[str, Any] | None = None + self.pipeline_after_sequences: list[int | None] = [] + + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + event = _event(context_id=self.context_id, state="TASK_STATE_WORKING") + event["result"]["metadata"] = {"iac_code": {"pendingPermissions": self.inputs}} + return event + + async def get_pipeline_state(self, _url, *, task_id, after_sequence=None): + del task_id + self.pipeline_after_sequences.append(after_sequence) + return self.pipeline_state + + +class FailNthSaveStore: + def __init__(self, fail_at: int) -> None: + self.fail_at = fail_at + self.calls = 0 + self.load_calls: list[str] = [] + self.values: dict[str, dict[str, Any]] = {} + + def load_thread(self, thread_id: str) -> dict[str, Any] | None: + self.load_calls.append(thread_id) + return self.values.get(thread_id) + + def save_thread(self, thread_id: str, state) -> None: + self.calls += 1 + if self.calls == self.fail_at: + raise AguiStateStoreError("injected state failure") + self.values[thread_id] = dict(state) + + +def _thread_state_path(state_dir: Path, thread_id: str = "thread-1") -> Path: + return state_dir / "threads" / f"{thread_id}.json" + + +def _permission(context_id: str, input_id: str, tool_id: str) -> dict[str, Any]: + return { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "contextId": context_id, + "inputId": input_id, + "toolUseId": tool_id, + "toolName": "bash", + "title": "Run a local shell command", + "purpose": "Execute a command for this task.", + "effect": "local_execution", + "target": "the current workspace", + "isReadOnly": False, + "prompt": "Run a local shell command. Allow once?", + "safeSummary": "bash: pwd", + "options": [{"id": "allow_once", "label": "Allow once"}, {"id": "deny", "label": "Deny"}], + "required": True, + } + + +@pytest.mark.parametrize( + ("scope", "metadata_key", "state"), + [ + ("normal", "input", "TASK_STATE_INPUT_REQUIRED"), + ("top-pipeline", "input", "TASK_STATE_INPUT_REQUIRED"), + ("sub-pipeline", "pendingPermissions", "TASK_STATE_WORKING"), + ], +) +@pytest.mark.asyncio +async def test_permission_interrupt_shapes_close_without_cancel( + tmp_path, + scope: str, + metadata_key: str, + state: str, +) -> None: + class ShapeClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + del kwargs + self.context_id = context_id + + async def events(): + yield _event(context_id=context_id) + event = _event(context_id=context_id, state=state) + value = _permission(context_id, f"permission-{scope}", f"tool-{scope}") + event["result"]["metadata"] = { + "iac_code": {metadata_key: [value] if metadata_key == "pendingPermissions" else value} + } + yield event + + return events() + + fake = ShapeClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=tmp_path / f"state-{scope}") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=adapter)), base_url="http://test" + ) as client: + response = await client.post("/", json=_payload(tmp_path)) + + assert _events(response)[-1]["outcome"]["type"] == "interrupt" + assert fake.cancelled == [] + + +@pytest.fixture(autouse=True) +def _allowed_cwd(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + + +@pytest.mark.asyncio +async def test_interrupt_is_durable_before_terminal_event_and_disconnect_does_not_cancel(tmp_path) -> None: + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=tmp_path / "state") + payload = _payload(tmp_path) + ticket = await adapter.admit(parse_run_input(payload), canonical_digest(payload)) + stream = adapter.stream(ticket) + + terminal = None + async for event in stream: + if event.type == EventType.RUN_FINISHED: + terminal = event + break + + assert terminal is not None + assert ticket.completed is True + assert ticket.paused is True + state = json.loads(_thread_state_path(tmp_path / "state").read_text(encoding="utf-8")) + assert list(state["execution"]["pending"]) == ["permission-1"] + await adapter.disconnect(ticket) + assert fake.cancelled == [] + await stream.aclose() + await adapter.aclose() + + +@pytest.mark.asyncio +async def test_disconnect_before_interrupt_is_durable_cancels_the_a2a_task(tmp_path) -> None: + fake = FakeA2AClient() + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=tmp_path / "state") + payload = _payload(tmp_path) + ticket = await adapter.admit(parse_run_input(payload), canonical_digest(payload)) + stream = adapter.stream(ticket) + + assert (await anext(stream)).type == EventType.RUN_STARTED + session_event = await anext(stream) + assert session_event.name == "iac-code.session.v1" + assert ticket.binding.task_id == "task-1" + + await adapter.disconnect(ticket) + + assert fake.cancelled == ["task-1"] + assert ticket.binding.task_id is None + await stream.aclose() + await adapter.aclose() + + +@pytest.mark.asyncio +async def test_restart_restores_interrupt_resume_and_cancel_identity(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient(interrupt=True) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + assert session["value"]["sessionId"] == "session-1" + fake.context_id = session["value"]["contextId"] + await first_adapter.aclose() + assert fake.cancelled == [] + fake.resume_preflight_calls.clear() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + resumed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + assert _events(resumed)[-1]["outcome"] == {"type": "success"} + assert fake.restored_sessions[-1] == (str(tmp_path), "session-1") + assert fake.resume_preflight_calls[:3] == [ + "ensure_session_restored", + "get_task", + "get_pipeline_state", + ] + resumed_session = next(event for event in _events(resumed) if event.get("name") == "iac-code.session.v1") + assert resumed_session["value"]["sessionId"] == "session-1" + assert fake.sent_parts[-1]["data"]["decision"] == "allow_once" + assert session["value"]["executionId"] in second_adapter._threads["thread-1"].terminal_execution_ids + assert ( + await second_adapter.cancel( + session["value"]["executionId"], + thread_id="thread-1", + ros_invocation_id="invocation-1", + ) + == "already_terminal" + ) + + +@pytest.mark.parametrize( + ("scope", "metadata_key", "snapshot_state"), + [ + ("normal", "input", "TASK_STATE_INPUT_REQUIRED"), + ("top-pipeline", "input", "TASK_STATE_INPUT_REQUIRED"), + ("sub-pipeline", "pendingPermissions", "TASK_STATE_WORKING"), + ], +) +@pytest.mark.asyncio +async def test_a2a_restart_summary_without_input_projection_preserves_durable_interrupt( + tmp_path, + scope: str, + metadata_key: str, + snapshot_state: str, +) -> None: + class InitialPermissionClient(FakeA2AClient): + def stream_message_parts(self, _url, _parts, *, context_id, **kwargs): + assert kwargs.get("task_id") is None + + async def events(): + yield _event(context_id=context_id) + event = _event(context_id=context_id, state=snapshot_state) + value = _permission(context_id, f"permission-{scope}", f"tool-{scope}") + event["result"]["metadata"] = { + "iac_code": {metadata_key: [value] if metadata_key == "pendingPermissions" else value} + } + yield event + + return events() + + class RestartedSummaryClient(FakeA2AClient): + async def get_task(self, _url, _task_id, *, history_length=None): + del history_length + self.resume_preflight_calls.append("get_task") + event = _event(context_id=self.context_id, state=snapshot_state) + event["result"].pop("metadata") + return event + + state_dir = tmp_path / f"state-{scope}" + first_adapter = AguiA2AAdapter( + a2a_url="http://a2a/", + client=InitialPermissionClient(), + state_dir=state_dir, + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + await first_adapter.aclose() + + restarted_a2a = RestartedSummaryClient() + restarted_a2a.context_id = session["contextId"] + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=restarted_a2a, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + resumed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": f"permission-{scope}", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + assert _events(resumed)[-1]["outcome"] == {"type": "success"} + assert restarted_a2a.resume_preflight_calls[:3] == [ + "ensure_session_restored", + "get_task", + "get_pipeline_state", + ] + assert restarted_a2a.sent_parts == [ + { + "data": { + "schemaVersion": 1, + "kind": "permission", + "requestTaskId": "task-1", + "inputId": f"permission-{scope}", + "toolUseId": f"tool-{scope}", + "decision": "allow_once", + }, + "mediaType": "application/json", + } + ] + await second_adapter.aclose() + + +@pytest.mark.asyncio +async def test_explicit_empty_input_projection_remains_authoritative_after_restart(tmp_path) -> None: + state_dir = tmp_path / "state" + first_fake = FakeA2AClient(interrupt=True) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=first_fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + await first_adapter.aclose() + + snapshot = SnapshotA2AClient([]) + snapshot.context_id = session["contextId"] + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=snapshot, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + resumed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + assert _events(resumed)[-1]["code"] == "UNKNOWN_INTERRUPT" + assert snapshot.sent_parts == [] + await second_adapter.aclose() + + +@pytest.mark.asyncio +async def test_resume_stops_before_get_task_when_session_backup_is_missing(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient(interrupt=True) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + fake.context_id = session["value"]["contextId"] + await first_adapter.aclose() + fake.session_available = False + fake.resume_preflight_calls.clear() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + resumed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + terminal = _events(resumed)[-1] + assert terminal["type"] == "RUN_ERROR" + assert terminal["code"] == "EXECUTION_LOST" + assert terminal["message"] == "The iac-code session to resume is unavailable." + assert fake.resume_preflight_calls == ["ensure_session_restored"] + assert fake.sent_parts == [] + + +@pytest.mark.asyncio +async def test_restart_restores_explicit_cancel_route(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient(interrupt=True) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + await first_adapter.aclose() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + response = await client.post( + f"/extensions/iac-code/v1/executions/{session['executionId']}/cancel", + json={"threadId": "thread-1", "rosInvocationId": "invocation-1"}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "cancelled" + assert fake.cancelled == ["task-1"] + + +@pytest.mark.asyncio +async def test_restart_allows_ordinary_new_turn_with_same_context(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient() + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + await first_adapter.aclose() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + replayed = await client.post("/", json=_payload(tmp_path)) + second = await client.post("/", json=_payload(tmp_path, run_id="run-2")) + + assert replayed.status_code == 409 + assert replayed.json()["error"]["code"] == "DUPLICATE_RUN_ID" + first_session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + second_session = next(event for event in _events(second) if event.get("name") == "iac-code.session.v1") + assert first_session["value"]["contextId"] == second_session["value"]["contextId"] + assert first_session["value"]["executionId"] != second_session["value"]["executionId"] + + +@pytest.mark.asyncio +async def test_resume_reconciles_multiple_current_permissions_before_applying_any_response(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = SnapshotA2AClient([]) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event for event in _events(first) if event.get("name") == "iac-code.session.v1") + fake.context_id = session["value"]["contextId"] + fake.inputs = [ + _permission(fake.context_id, "permission-1", "tool-1"), + _permission(fake.context_id, "permission-2", "tool-2"), + ] + await first_adapter.aclose() + + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + response = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + interrupt = _events(response)[-1]["outcome"] + assert interrupt["type"] == "interrupt" + assert {item["id"] for item in interrupt["interrupts"]} == {"permission-1", "permission-2"} + assert fake.sent_parts == [] + + +@pytest.mark.asyncio +async def test_restart_retry_does_not_reapply_a_permission_accepted_before_partial_failure(tmp_path) -> None: + class PartialFailureClient(SnapshotA2AClient): + def __init__(self) -> None: + super().__init__([]) + self.fail_permission_2 = True + + async def send_message_parts(self, url, parts, **kwargs): + del url, kwargs + input_id = parts[0]["data"]["inputId"] + if input_id == "permission-2" and self.fail_permission_2: + raise RuntimeError("injected second response failure") + self.sent_parts.extend(parts) + return object() + + state_dir = tmp_path / "state" + fake = PartialFailureClient() + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + fake.context_id = session["contextId"] + fake.inputs = [ + _permission(fake.context_id, "permission-1", "tool-1"), + _permission(fake.context_id, "permission-2", "tool-2"), + ] + discovered = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[{"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}], + ), + ) + failed = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-3", + resume=[ + {"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}, + {"interruptId": "permission-2", "status": "resolved", "payload": {"decision": "deny"}}, + ], + ), + ) + + discovered_events = _events(discovered) + assert {item["id"] for item in discovered_events[-1]["outcome"]["interrupts"]} == { + "permission-1", + "permission-2", + } + assert all(event.get("name") != "iac-code.session.v1" for event in discovered_events) + failed_events = _events(failed) + assert failed_events[-1]["code"] == "A2A_UNAVAILABLE" + assert all(event.get("name") != "iac-code.session.v1" for event in failed_events) + assert set(first_adapter._threads["thread-1"].pending) == {"permission-2"} + assert [part["data"]["inputId"] for part in fake.sent_parts] == ["permission-1"] + await first_adapter.aclose() + + fake.fail_permission_2 = False + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), base_url="http://test" + ) as client: + retried = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-4", + resume=[ + {"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}, + {"interruptId": "permission-2", "status": "resolved", "payload": {"decision": "deny"}}, + ], + ), + ) + + assert _events(retried)[-1]["outcome"] == {"type": "success"} + assert any(event.get("name") == "iac-code.session.v1" for event in _events(retried)) + assert [part["data"]["inputId"] for part in fake.sent_parts] == ["permission-1", "permission-2"] + + +@pytest.mark.asyncio +async def test_cancelled_permission_resume_reschedules_interrupt_expiry(tmp_path) -> None: + class BlockingResumeClient(FakeA2AClient): + def stream_message_parts(self, url, parts, **kwargs): + if kwargs.get("task_id") is None: + return super().stream_message_parts(url, parts, **kwargs) + + async def events(): + await asyncio.Event().wait() + yield # pragma: no cover - keeps this an async generator + + return events() + + fake = BlockingResumeClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=tmp_path / "state") + initial_payload = _payload(tmp_path) + initial_ticket = await adapter.admit(parse_run_input(initial_payload), canonical_digest(initial_payload)) + initial_stream = adapter.stream(initial_ticket) + async for event in initial_stream: + if event.type == EventType.RUN_FINISHED: + break + await initial_stream.aclose() + fake.context_id = initial_ticket.binding.context_id + + resume_payload = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + resume_ticket = await adapter.admit(parse_run_input(resume_payload), canonical_digest(resume_payload)) + stream = adapter.stream(resume_ticket) + assert (await anext(stream)).type == EventType.RUN_STARTED + + blocked_read = asyncio.create_task(anext(stream)) + await asyncio.sleep(0.01) + blocked_read.cancel() + with pytest.raises(asyncio.CancelledError): + await blocked_read + + assert resume_ticket.binding.pending + assert resume_ticket.binding.expiry_task is not None + assert not resume_ticket.binding.expiry_task.done() + await stream.aclose() + await adapter.aclose() + + +@pytest.mark.asyncio +async def test_resume_uses_persisted_pipeline_cursor_instead_of_get_task_suffix(tmp_path) -> None: + class PipelineBatchClient(SnapshotA2AClient): + async def get_task(self, url, task_id, *, history_length=None): + event = await super().get_task(url, task_id, history_length=history_length) + event["result"]["metadata"]["iac_code"]["pipelineBatch"] = { + "events": [ + { + "eventId": "step-7", + "eventType": "candidate_step_completed", + "sequence": 7, + "candidateStep": {"id": "candidate-b"}, + } + ] + } + return event + + state_dir = tmp_path / "state" + fake = PipelineBatchClient([]) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + fake.context_id = session["contextId"] + fake.inputs = [_permission(fake.context_id, "permission-1", "tool-1")] + fake.pipeline_state = { + "snapshot": {"schemaVersion": "1.0", "pipelineRunId": "pipeline-1", "lastSequence": 7}, + "events": [], + } + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + response = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + response_events = _events(response) + assert fake.pipeline_after_sequences[-2:] == [0, 7] + assert not any(event.get("type") == "STEP_FINISHED" for event in response_events) + + +@pytest.mark.asyncio +async def test_resume_rejects_incomplete_duplicate_wrong_owner_and_repeated_payloads(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + app = create_app(adapter=adapter) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first = await client.post("/", json=_payload(tmp_path)) + session = next(event["value"] for event in _events(first) if event.get("name") == "iac-code.session.v1") + fake.context_id = session["contextId"] + incomplete = await client.post("/", json=_payload(tmp_path, run_id="run-2", resume=[])) + duplicate = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-3", + resume=[ + {"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}, + {"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}, + ], + ), + ) + wrong_owner_payload = _payload( + tmp_path, + run_id="run-4", + resume=[{"interruptId": "permission-1", "status": "resolved", "payload": {"decision": "deny"}}], + ) + wrong_owner_payload["forwardedProps"]["iacCode"]["rosInvocationId"] = "other-invocation" + wrong_owner = await client.post("/", json=wrong_owner_payload) + applied = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-5", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + repeated = await client.post( + "/", + json=_payload( + tmp_path, + run_id="run-6", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ), + ) + + assert _events(incomplete)[-1]["code"] == "INCOMPLETE_RESUME" + assert _events(duplicate)[-1]["code"] == "INCOMPLETE_RESUME" + assert wrong_owner.status_code == 409 + assert wrong_owner.json()["error"]["code"] == "EXECUTION_LOST" + assert _events(applied)[-1]["outcome"] == {"type": "success"} + assert _events(repeated)[-1]["code"] == "RESUME_ALREADY_APPLIED" + assert len(fake.sent_parts) == 1 + + +@pytest.mark.asyncio +async def test_state_write_failure_cancels_a2a_without_emitting_interrupt(tmp_path) -> None: + fake = FakeA2AClient(interrupt=True) + store = FailNthSaveStore(fail_at=3) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_store=store) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=adapter)), base_url="http://test" + ) as client: + response = await client.post("/", json=_payload(tmp_path)) + + events = _events(response) + assert events[-1]["type"] == "RUN_ERROR" + assert events[-1]["code"] == "STATE_PERSISTENCE_FAILED" + assert all(event.get("outcome", {}).get("type") != "interrupt" for event in events) + assert fake.cancelled == ["task-1"] + + +@pytest.mark.asyncio +async def test_restart_keeps_original_absolute_interrupt_expiry(tmp_path) -> None: + state_dir = tmp_path / "state" + first_fake = FakeA2AClient(interrupt=True) + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=first_fake, state_dir=state_dir, interrupt_ttl=1) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), base_url="http://test" + ) as client: + response = await client.post("/", json=_payload(tmp_path)) + expires_at = datetime.fromisoformat( + _events(response)[-1]["outcome"]["interrupts"][0]["expiresAt"].replace("Z", "+00:00") + ) + await asyncio.sleep(0.55) + await first_adapter.aclose() + + second_fake = FakeA2AClient(interrupt=True) + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=second_fake, state_dir=state_dir, interrupt_ttl=20) + await second_adapter.start() + assert second_adapter._threads == {} + resume_payload = _payload( + tmp_path, + run_id="run-2", + resume=[ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"}, + } + ], + ) + await second_adapter.admit(parse_run_input(resume_payload), canonical_digest(resume_payload)) + remaining = max(0.0, (expires_at - datetime.now(timezone.utc)).total_seconds()) + + async def wait_until_cancelled() -> None: + while not second_fake.cancelled: + await asyncio.sleep(0.02) + + await asyncio.wait_for(wait_until_cancelled(), timeout=remaining + 3.0) + assert second_fake.cancelled == ["task-1"] + state = json.loads(_thread_state_path(state_dir).read_text(encoding="utf-8")) + assert state["execution"]["taskId"] is None + assert state["execution"]["pending"] == {} + await second_adapter.aclose() + + +@pytest.mark.asyncio +async def test_persisted_state_excludes_request_messages_and_credentials(tmp_path) -> None: + state_dir = tmp_path / "state" + fake = FakeA2AClient(interrupt=True) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=fake, state_dir=state_dir) + payload = _payload(tmp_path) + payload["messages"][0]["content"] = "private-user-message" + payload["forwardedProps"]["iacCode"].update( + { + "llmApiKey": "llm-secret", + "alibabaCloud": { + "accessKeyId": "ak-secret", + "accessKeySecret": "sk-secret", + "securityToken": "sts-secret", + }, + } + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=adapter)), base_url="http://test" + ) as client: + await client.post("/", json=payload) + + thread_path = _thread_state_path(state_dir) + raw = thread_path.read_text(encoding="utf-8") + for secret in ("private-user-message", "llm-secret", "ak-secret", "sk-secret", "sts-secret"): + assert secret not in raw + if os.name != "nt": + assert stat.S_IMODE(state_dir.stat().st_mode) == 0o700 + assert stat.S_IMODE((state_dir / "threads").stat().st_mode) == 0o700 + assert stat.S_IMODE(thread_path.stat().st_mode) == 0o600 + + +def test_file_state_store_rejects_unknown_schema(tmp_path: Path) -> None: + state_dir = tmp_path / "state" + store = FileAguiThreadStateStore(state_dir) + thread_path = store.path_for_thread("thread-1") + thread_path.parent.mkdir(parents=True) + thread_path.write_text('{"schemaVersion":999,"threadId":"thread-1"}', encoding="utf-8") + + with pytest.raises(AguiStateStoreError): + store.load_thread("thread-1") + + +@pytest.mark.asyncio +async def test_adapter_lazily_loads_only_the_requested_thread(tmp_path: Path) -> None: + store = FailNthSaveStore(fail_at=100) + adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_store=store) + + assert store.load_calls == [] + await adapter.start() + assert store.load_calls == [] + + payload = _payload(tmp_path) + await adapter.admit(parse_run_input(payload), canonical_digest(payload)) + + assert store.load_calls == ["thread-1"] + assert set(store.values) == {"thread-1"} + await adapter.aclose() + + +def test_file_state_store_uses_readable_safe_ids_and_reversible_unsafe_ids(tmp_path: Path) -> None: + store = FileAguiThreadStateStore(tmp_path / "state") + safe_thread_id = "8473547e-c8ed-4aef-a84c-603a6a8d42da" + unsafe_thread_id = "../external/thread" + safe_document = {"schemaVersion": 1, "threadId": safe_thread_id} + unsafe_document = {"schemaVersion": 1, "threadId": unsafe_thread_id} + + store.save_thread(safe_thread_id, safe_document) + store.save_thread(unsafe_thread_id, unsafe_document) + + safe_path = store.path_for_thread(safe_thread_id) + unsafe_path = store.path_for_thread(unsafe_thread_id) + assert safe_path.name == f"{safe_thread_id}.json" + assert unsafe_path.parent == store.threads_dir + assert unsafe_path.name.startswith("aguiid~") + assert "/" not in unsafe_path.name and "\\" not in unsafe_path.name + assert store.load_thread(safe_thread_id) == safe_document + assert store.load_thread(unsafe_thread_id) == unsafe_document + + +def test_file_state_store_avoids_case_only_filename_collisions(tmp_path: Path) -> None: + store = FileAguiThreadStateStore(tmp_path / "state") + upper_thread_id = "Thread-1" + lower_thread_id = "thread-1" + upper_document = {"schemaVersion": 1, "threadId": upper_thread_id} + lower_document = {"schemaVersion": 1, "threadId": lower_thread_id} + + store.save_thread(upper_thread_id, upper_document) + store.save_thread(lower_thread_id, lower_document) + + upper_path = store.path_for_thread(upper_thread_id) + lower_path = store.path_for_thread(lower_thread_id) + assert upper_path.name.casefold() != lower_path.name.casefold() + assert upper_path.stem.removeprefix("aguiid~") == upper_thread_id.encode("utf-8").hex() + assert store.load_thread(upper_thread_id) == upper_document + assert store.load_thread(lower_thread_id) == lower_document + + +@pytest.mark.parametrize( + "thread_id", + [ + "T" * 115, + "会话" * 40, + ], +) +def test_file_state_store_bounds_long_encoded_thread_filenames(tmp_path: Path, thread_id: str) -> None: + store = FileAguiThreadStateStore(tmp_path / "state") + document = {"schemaVersion": 1, "threadId": thread_id} + + store.save_thread(thread_id, document) + + path = store.path_for_thread(thread_id) + assert path.parent == store.threads_dir + assert path.name.startswith("aguihash~") + assert len(path.name) < 100 + assert store.load_thread(thread_id) == document + + +def test_file_state_store_keeps_distinct_long_thread_ids_isolated(tmp_path: Path) -> None: + store = FileAguiThreadStateStore(tmp_path / "state") + first_thread_id = "T" * 114 + "A" + second_thread_id = "T" * 114 + "B" + first_document = {"schemaVersion": 1, "threadId": first_thread_id} + second_document = {"schemaVersion": 1, "threadId": second_thread_id} + + store.save_thread(first_thread_id, first_document) + store.save_thread(second_thread_id, second_document) + + assert store.path_for_thread(first_thread_id) != store.path_for_thread(second_thread_id) + assert store.load_thread(first_thread_id) == first_document + assert store.load_thread(second_thread_id) == second_document + + +@pytest.mark.asyncio +async def test_shared_state_dir_keeps_interleaved_threads_isolated(tmp_path: Path) -> None: + state_dir = tmp_path / "state" + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + first_payload = _payload(tmp_path) + second_payload = _payload(tmp_path) + second_payload["threadId"] = "thread-2" + second_payload["forwardedProps"]["iacCode"]["rosInvocationId"] = "invocation-2" + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), + base_url="http://test", + ) as first_client, httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), + base_url="http://test", + ) as second_client: + first_response = await first_client.post("/", json=first_payload) + second_response = await second_client.post("/", json=second_payload) + + assert first_response.status_code == 200 + assert second_response.status_code == 200 + assert _thread_state_path(state_dir, "thread-1").is_file() + assert _thread_state_path(state_dir, "thread-2").is_file() + assert not (state_dir / "adapter-state.json").exists() + first_state = json.loads(_thread_state_path(state_dir, "thread-1").read_text(encoding="utf-8")) + second_state = json.loads(_thread_state_path(state_dir, "thread-2").read_text(encoding="utf-8")) + assert first_state["threadId"] == "thread-1" + assert second_state["threadId"] == "thread-2" + assert first_state["runDigests"] == {"run-1": canonical_digest(first_payload)} + assert second_state["runDigests"] == {"run-1": canonical_digest(second_payload)} + + restarted_first = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + restarted_second = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=restarted_first)), + base_url="http://test", + ) as first_client, httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=restarted_second)), + base_url="http://test", + ) as second_client: + first_replay = await first_client.post("/", json=first_payload) + second_replay = await second_client.post("/", json=second_payload) + + assert first_replay.status_code == 409 + assert second_replay.status_code == 409 + assert first_replay.json()["error"]["code"] == "DUPLICATE_RUN_ID" + assert second_replay.json()["error"]["code"] == "DUPLICATE_RUN_ID" + + +@pytest.mark.asyncio +async def test_corrupt_thread_state_does_not_block_another_thread(tmp_path: Path) -> None: + state_dir = tmp_path / "state" + first_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + second_adapter = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + first_payload = _payload(tmp_path) + second_payload = _payload(tmp_path) + second_payload["threadId"] = "thread-2" + second_payload["forwardedProps"]["iacCode"]["rosInvocationId"] = "invocation-2" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=first_adapter)), + base_url="http://test", + ) as first_client, httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=second_adapter)), + base_url="http://test", + ) as second_client: + await first_client.post("/", json=first_payload) + await second_client.post("/", json=second_payload) + + _thread_state_path(state_dir, "thread-1").write_text("not-json", encoding="utf-8") + restarted = AguiA2AAdapter(a2a_url="http://a2a/", client=FakeA2AClient(), state_dir=state_dir) + second_next = _payload(tmp_path, run_id="run-2") + second_next["threadId"] = "thread-2" + second_next["forwardedProps"]["iacCode"]["rosInvocationId"] = "invocation-3" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(adapter=restarted)), + base_url="http://test", + ) as client: + healthy = await client.post("/", json=second_next) + corrupt = await client.post("/", json=first_payload) + + assert healthy.status_code == 200 + assert corrupt.status_code == 503 + assert corrupt.json()["error"]["code"] == "STATE_UNAVAILABLE" diff --git a/tests/agui/test_process.py b/tests/agui/test_process.py new file mode 100644 index 00000000..cff7241c --- /dev/null +++ b/tests/agui/test_process.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +import contextlib +import sys +from types import SimpleNamespace +from typing import Any + +import pytest + +from iac_code.agui.process import LocalA2AProcess +from iac_code.agui.server import _local_a2a_url, run_server + + +class _Process: + def poll(self) -> None: + return None + + +class _HealthResponse: + status_code = 200 + + +def test_managed_a2a_child_inherits_execution_environment_without_interpreting_it( + monkeypatch, + tmp_path, +) -> None: + captured: dict[str, Any] = {} + + def popen(command, *, env): + captured["command"] = command + captured["env"] = env + return _Process() + + monkeypatch.setenv("IAC_CODE_AGUI_ALLOWED_CWDS", str(tmp_path)) + monkeypatch.setenv("IAC_CODE_A2A_SAFE_MODE", "true") + monkeypatch.setenv("IAC_CODE_MODE", "pipeline") + monkeypatch.setenv("IAC_CODE_A2A_EXTREME_PERFORMANCE", "true") + monkeypatch.setattr("iac_code.agui.process.subprocess.Popen", popen) + monkeypatch.setattr("iac_code.agui.process.httpx.get", lambda *_args, **_kwargs: _HealthResponse()) + + process = LocalA2AProcess() + process.start() + + command = captured["command"] + environment = captured["env"] + assert command[3] == "a2a" + assert environment["IACCODE_A2A_ALLOWED_CWDS"] == str(tmp_path) + assert environment["IAC_CODE_A2A_SAFE_MODE"] == "true" + assert environment["IAC_CODE_MODE"] == "pipeline" + assert environment["IAC_CODE_A2A_EXTREME_PERFORMANCE"] == "true" + assert environment["IACCODE_A2A_HTTP_TOKEN"] == process.token + + +def test_explicit_a2a_url_must_be_loopback() -> None: + assert _local_a2a_url("http://127.0.0.1:41242") == "http://127.0.0.1:41242/" + with pytest.raises(ValueError, match="loopback"): + _local_a2a_url("https://a2a.example.com") + + +def test_idle_shutdown_requests_uvicorn_exit_and_runs_lifespan_cleanup(monkeypatch) -> None: + servers = [] + endpoint_closed: list[bool] = [] + + class Config: + def __init__(self, app, **_kwargs) -> None: + self.app = app + + class Server: + def __init__(self, config) -> None: + self.config = config + self.should_exit = False + servers.append(self) + + def run(self) -> None: + async def serve() -> None: + async with self.config.app.router.lifespan_context(self.config.app): + await asyncio.sleep(0.15) + + asyncio.run(serve()) + + @contextlib.contextmanager + def endpoint(**_kwargs): + try: + yield "http://127.0.0.1:41242/", None + finally: + endpoint_closed.append(True) + + monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(Config=Config, Server=Server)) + monkeypatch.setattr("iac_code.agui.server._a2a_endpoint", endpoint) + + run_server(idle_shutdown=0.01) + + assert len(servers) == 1 + assert servers[0].should_exit is True + assert endpoint_closed == [True] diff --git a/tests/cli/test_a2a_command.py b/tests/cli/test_a2a_command.py index 500e2462..2e377255 100644 --- a/tests/cli/test_a2a_command.py +++ b/tests/cli/test_a2a_command.py @@ -46,6 +46,68 @@ def test_a2a_command_rejects_removed_advanced_flags() -> None: assert "No such option: --token" in result.stderr +def test_agui_command_loads_online_adapter_config(monkeypatch, tmp_path) -> None: + captured = {} + logging_setup = {} + + config = tmp_path / "agui.yml" + config.write_text( + "\n".join( + [ + "host: 0.0.0.0", + "port: 41243", + "a2a_url: http://127.0.0.1:41242", + "state_dir: /home/iac_code_config/agui", + "interrupt_ttl: 540", + "idle_shutdown: 0", + "log_stdout: true", + ] + ), + encoding="utf-8", + ) + monkeypatch.delenv("IAC_CODE_AGUI_AUTH_TOKEN", raising=False) + monkeypatch.delenv("IAC_CODE_AGUI_A2A_TOKEN", raising=False) + monkeypatch.setattr( + "iac_code.cli.main.setup_logging", + lambda **kwargs: logging_setup.update(kwargs), + ) + monkeypatch.setattr( + "iac_code.agui.server.run_server", + lambda **kwargs: captured.update(kwargs), + ) + + result = CliRunner().invoke(app, ["agui", "--config", str(config)]) + + assert result.exit_code == 0 + assert logging_setup == { + "session_id": "agui-adapter", + "debug": False, + "stdout": True, + } + assert captured == { + "host": "0.0.0.0", + "port": 41243, + "a2a_url": "http://127.0.0.1:41242", + "a2a_token": None, + "interrupt_ttl": 540, + "state_dir": "/home/iac_code_config/agui", + "debug": False, + "auth_token": None, + "idle_shutdown": 0.0, + } + + +def test_agui_command_translates_runtime_validation_errors(monkeypatch, tmp_path) -> None: + config = tmp_path / "agui.yml" + config.write_text("port: 0\n", encoding="utf-8") + monkeypatch.setattr("iac_code.cli.main._", lambda message: f"i18n:{message}") + + result = CliRunner().invoke(app, ["agui", "--config", str(config)]) + + assert result.exit_code == 1 + assert "i18n:--port must be between 1 and 65535." in result.stderr + + def test_a2a_client_help_groups_client_commands() -> None: result = CliRunner().invoke(app, ["a2a-client", "--help"]) diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index 8ff0fc1a..31a8349a 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -760,6 +760,9 @@ async def fake_stream(*a, **kw): events = [e async for e in mgr.stream(messages=[Message.user("hi")], system="sys")] types = [e.type for e in events] assert "message_start" in types and "text_delta" in types and "message_end" in types + terminal = next(event for event in events if isinstance(event, MessageEndEvent)) + assert terminal.usage.provider == "asyncmock" + assert terminal.usage.model == "claude-sonnet-4-6" async def test_stream_records_normalized_token_usage_on_all_signals(self): mock_provider = AsyncMock() diff --git a/tests/providers/test_manager_stream_lifecycle.py b/tests/providers/test_manager_stream_lifecycle.py index 84b51c74..08a1e687 100644 --- a/tests/providers/test_manager_stream_lifecycle.py +++ b/tests/providers/test_manager_stream_lifecycle.py @@ -259,7 +259,9 @@ async def completion(*args, **kwargs): tool_uses=[], stop_reason="end_turn", usage=Usage(), - ) + ), + provider_name="anthropic", + model="claude-sonnet-4-6", ) monkeypatch.setattr(manager, "_complete_with_retry_result", completion) diff --git a/uv.lock b/uv.lock index ec2bfda0..8d613380 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,18 @@ signing = [ { name = "pyjwt" }, ] +[[package]] +name = "ag-ui-protocol" +version = "0.1.20" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/7a/0db4c6233ccf39654e45af0e532efa87abc0eb7828b16f9e63014881e75f/ag_ui_protocol-0.1.20.tar.gz", hash = "sha256:fadc9600f492a9dde8b2b027171bb471a899430e53291a0b882a09d3f5416145" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/06/57/55a4a74a5580ef74d2e326f455be45256d4f11260b493174ae0a81626be4/ag_ui_protocol-0.1.20-py3-none-any.whl", hash = "sha256:a15cced52603327b0ba32e95a8aca87e9067f3e69281addfc3fecdaa16a1d485" }, +] + [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -1589,6 +1601,12 @@ a2a-redis = [ a2a-signing = [ { name = "a2a-sdk", extra = ["signing"] }, ] +agui = [ + { name = "a2a-sdk", extra = ["http-server", "signing"] }, + { name = "ag-ui-protocol" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] diagram = [ { name = "termaid", marker = "python_full_version >= '3.11'" }, ] @@ -1624,8 +1642,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", extras = ["http-server", "signing"], marker = "extra == 'a2a'", specifier = ">=1.0.2,<2" }, + { name = "a2a-sdk", extras = ["http-server", "signing"], marker = "extra == 'agui'", specifier = ">=1.0.2,<2" }, { name = "a2a-sdk", extras = ["http-server", "signing"], marker = "extra == 'http'", specifier = ">=1.0.2,<2" }, { name = "a2a-sdk", extras = ["signing"], marker = "extra == 'a2a-signing'", specifier = ">=1.0.2,<2" }, + { name = "ag-ui-protocol", marker = "extra == 'agui'", specifier = "==0.1.20" }, { name = "agent-client-protocol", specifier = ">=0.9.0" }, { name = "aiohttp", specifier = ">=3.10,<4" }, { name = "alibabacloud-credentials", specifier = ">=1.0.8" }, @@ -1662,6 +1682,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'a2a-redis'", specifier = ">=5.0.0" }, { name = "rich", specifier = ">=13.0" }, { name = "starlette", marker = "extra == 'a2a'", specifier = ">=0.39.0" }, + { name = "starlette", marker = "extra == 'agui'", specifier = ">=0.39.0" }, { name = "starlette", marker = "extra == 'http'", specifier = ">=0.39.0" }, { name = "termaid", marker = "python_full_version >= '3.11' and extra == 'diagram'", specifier = ">=0.1" }, { name = "tiktoken", specifier = ">=0.7.0" }, @@ -1669,9 +1690,10 @@ requires-dist = [ { name = "tree-sitter-bash", specifier = ">=0.25,<0.26" }, { name = "typer", specifier = ">=0.9.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'a2a'", specifier = ">=0.30.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'agui'", specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'http'", specifier = ">=0.30.0" }, ] -provides-extras = ["http", "a2a", "a2a-signing", "a2a-grpc", "a2a-redis", "diagram"] +provides-extras = ["http", "a2a", "a2a-signing", "a2a-grpc", "a2a-redis", "agui", "diagram"] [package.metadata.requires-dev] desktop = [ diff --git a/website/docs/agui/getting-started.md b/website/docs/agui/getting-started.md new file mode 100644 index 00000000..345b8f53 --- /dev/null +++ b/website/docs/agui/getting-started.md @@ -0,0 +1,270 @@ +--- +sidebar_position: 2 +title: Getting Started +description: Install, start, and call the iac-code AG-UI adapter. +--- + +# AG-UI Getting Started + +## Prerequisites + +1. Python 3.10 or later is installed. +2. An LLM provider is configured for iac-code. See [Authentication](../configuration/authentication.md). +3. If the task accesses Alibaba Cloud, cloud credentials are configured or temporary credentials are supplied per request. +4. An absolute workspace path is available for iac-code to read and write. + +Install the AG-UI dependencies: + +```bash +pip install "iac-code[agui]" +``` + +When developing from the source repository: + +```bash +uv sync --extra agui +``` + +## Option 1: Start a managed local A2A kernel + +For the simplest local setup, omit `--a2a-url`: + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +The adapter chooses an available loopback port, starts a managed `iac-code a2a` child process, and stops it when the adapter exits. The child inherits the current iac-code configuration and runtime environment. + +This mode is suitable for local development and single-process lifecycle management. Use the next option when production process supervision needs to manage the two services independently. + +## Option 2: Connect to an independent A2A kernel + +Start the A2A server first: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +Then start the AG-UI adapter: + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +The services keep separate responsibilities and ports. A2A may continue serving A2A clients while the AG-UI adapter reaches it only through the loopback interface. + +`--thinking-exposure all` lets the adapter convert raw thinking into standard `REASONING_*` events. Enable raw thinking only for trusted clients. Keep the A2A default, `tool-trace`, when reasoning content should not be exposed. + +If the A2A server uses a bearer token: + +```bash +export IACCODE_A2A_HTTP_TOKEN="a2a-local-secret" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Give the adapter the same upstream token: + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="a2a-local-secret" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## YAML configuration + +Static startup settings can be stored in YAML: + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +Start the adapter with: + +```bash +iac-code agui --config agui-server.yml +``` + +Explicit CLI arguments override YAML. Inject sensitive values such as tokens through environment variables instead of storing them in the config file. + +Common settings: + +| CLI / YAML | Default | Meaning | +|------------|---------|---------| +| `--host` / `host` | `127.0.0.1` | AG-UI HTTP bind address | +| `--port` / `port` | `8000` | AG-UI HTTP port; deployment examples use `41243` | +| `--a2a-url` / `a2a-url` | empty | Local A2A URL; empty starts a managed child | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Seconds an interrupt remains resumable | +| `--state-dir` / `state-dir` | `/agui` | AG-UI thread-state directory | +| `--idle-shutdown` / `idle-shutdown` | `0` | Idle shutdown delay; `0` disables it | +| `--debug` / `debug` | `false` | Debug logging | +| `--log-stdout` / `log-stdout` | `false` | Mirror logs to stdout | + +Related environment variables: + +| Variable | Purpose | +|----------|---------| +| `IAC_CODE_AGUI_HOST` | AG-UI bind address | +| `IAC_CODE_AGUI_PORT` | AG-UI port | +| `IAC_CODE_AGUI_A2A_URL` | Local A2A upstream URL | +| `IAC_CODE_AGUI_A2A_TOKEN` | A2A upstream bearer token | +| `IAC_CODE_AGUI_AUTH_TOKEN` | Bearer token protecting the AG-UI endpoint | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Interrupt lifetime | +| `IAC_CODE_AGUI_STATE_DIR` | AG-UI thread-state directory | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | Allowed workspace roots, separated with the OS path separator | +| `IAC_CODE_CONFIG_DIR` | iac-code configuration root and default AG-UI state parent | + +## Health check + +```bash +curl http://127.0.0.1:41243/health +``` + +Example response: + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "current iac-code version" +} +``` + +## Use the official JavaScript client + +Install the verified client version: + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +This example connects directly to `iac-code agui`. It uses the standard `HttpAgent` and supplies iac-code runtime properties in `forwardedProps`: + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const threadId = randomUUID(); +const rosInvocationId = randomUUID(); +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId, + // When IAC_CODE_AGUI_AUTH_TOKEN is configured: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId, + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "en", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "Create a VPC template with two vSwitches.", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[tool] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[step] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +When the endpoint uses a bearer token, pass `Authorization` through `HttpAgent.headers`. + +A browser application normally connects through a same-origin backend or reverse proxy. The adapter does not add a cross-origin policy. + +## Handle interrupts + +The official client keeps `RUN_FINISHED.outcome.interrupts` in `agent.pendingInterrupts`. Build each response from its `responseSchema`, then submit it in a new run: + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +This payload applies only to permission interrupts whose schema requires `decision`. Questions and option selection have their own schemas; do not assume every interrupt has the same shape. + +A resume must: + +- use the original `threadId`; +- use a new `runId` (the official client creates one by default); +- retain the interrupted execution's `rosInvocationId`; +- cover every currently pending interrupt in one request; +- provide a payload matching `responseSchema` for `status: "resolved"`; +- use `status: "cancelled"` when the user chooses not to continue. + +## Start a Pipeline + +Set `runMode` to `pipeline` and optionally select a Pipeline: + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +Clients should handle `STEP_*`, `TOOL_CALL_*`, `ACTIVITY_SNAPSHOT`, and `CUSTOM`. A generic client that does not recognize iac-code custom events can still process all standard events normally. + +## Workspace and temporary credentials + +`cwd` is not fixed at server startup. Every request must provide an absolute path under a root allowed by `IAC_CODE_AGUI_ALLOWED_CWDS` or `IACCODE_A2A_ALLOWED_CWDS`. + +The caller may supply a per-request model, LLM key, and Alibaba Cloud temporary credentials through `forwardedProps.iacCode`. The adapter does not write those secrets to its thread-state file. It forwards them to the A2A execution kernel, which applies the normal A2A request override rules. + +## State directory + +The default layout is: + +```text +/agui/ + threads/ + .json +``` + +Each thread is written independently, and startup does not scan all historical threads. Normal UUIDs remain readable. Unsafe IDs are encoded, and unusually long IDs use a fixed-length file key. The JSON document always stores and validates the original `threadId`. + +This directory stores only adapter mappings, interrupts, and idempotency state. It does not store conversation content or request credentials. Do not edit its JSON files manually. + +## Next steps + +- [AG-UI overview](./overview.md) +- [Protocol reference](./protocol-reference.md) diff --git a/website/docs/agui/overview.md b/website/docs/agui/overview.md new file mode 100644 index 00000000..414ff62b --- /dev/null +++ b/website/docs/agui/overview.md @@ -0,0 +1,135 @@ +--- +sidebar_position: 1 +title: AG-UI Protocol +description: Architecture, capabilities, and use cases for iac-code's AG-UI integration. +--- + +# AG-UI Protocol + +## What is AG-UI? + +The [Agent-User Interaction Protocol (AG-UI)](https://docs.ag-ui.com/concepts/architecture) is an event-stream protocol between agents and user-facing applications. A client starts a run with `RunAgentInput` and receives structured text, reasoning, tool-call, step, state, and interrupt events over HTTP Server-Sent Events (SSE). + +AG-UI is a good fit for web consoles, chat clients, IDE extensions, and other applications that need to show agent execution in real time. Instead of consuming only final text, an AG-UI client can render model output, tool arguments and results, Pipeline steps, and operations awaiting user confirmation separately. + +## iac-code architecture + +iac-code uses an **A2A execution kernel with an AG-UI protocol adapter**: + +```text +AG-UI client + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Agent loop / Pipeline / LLM / Alibaba Cloud API +``` + +`iac-code a2a` is the sole execution kernel. It owns: + +- normal conversations and Pipeline execution; +- iac-code sessions, A2A contexts, and tasks; +- tool permissions, questions, option selection, and recovery; +- execution lifecycle and cancellation; +- LLM and Alibaba Cloud API calls. + +`iac-code agui` does not create a second Agent runtime or execute Pipelines directly. It only: + +- converts AG-UI `RunAgentInput` into A2A requests; +- projects A2A events into standard AG-UI events; +- maps `threadId/runId` to A2A `contextId/taskId`; +- converts AG-UI `resume[]` into A2A input recovery; +- persists protocol mappings and pending interrupts; +- forwards cancellation to A2A. + +As a result, AG-UI and A2A do not maintain separate execution semantics. Model selection, cloud credentials, permission rules, and Pipeline behavior are ultimately handled by the same A2A runtime. + +## Standard protocol and iac-code extensions + +The external stream uses standard AG-UI events, including: + +- `RUN_STARTED`, `RUN_FINISHED`, and `RUN_ERROR`; +- `TEXT_MESSAGE_*`; +- `REASONING_*`; +- `TOOL_CALL_*`; +- `STEP_STARTED` and `STEP_FINISHED`; +- `ACTIVITY_SNAPSHOT`. + +Only useful iac-code Pipeline information without a standard equivalent is emitted as a namespaced `CUSTOM` event. A generic AG-UI client may ignore those events without affecting text, tool calls, interrupts, or the run lifecycle. + +Requests remain standard `RunAgentInput` envelopes. iac-code uses the standard `forwardedProps` field for the workspace, run mode, and other required runtime data: + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "request-identity", + "cwd": "/absolute/workspace/path", + "runMode": "normal" + } + } +} +``` + +A generic AG-UI client can therefore consume iac-code's standard events directly. When it calls `iac-code agui` directly, it must still provide runtime fields such as `cwd` under `forwardedProps.iacCode`. + +## Supported interactions + +### Multi-turn normal conversations + +Keep the same `threadId` for the conversation and use a new `runId` for each user turn. The adapter binds the thread to one iac-code session. After one turn completes, the next message starts with a new HTTP/SSE request; it never continues on the previous, already completed SSE response. + +### Pipeline + +Set `forwardedProps.iacCode.runMode` to `pipeline`. The A2A Pipeline kernel still performs the execution. Top-level steps become standard `STEP_*` events, and agent text, reasoning, and tools use their corresponding standard events. Candidate information, stack progress, and cleanup progress that have no standard equivalent are emitted through `iac-code.pipeline.v1` custom events. + +Parallel sub-pipelines use distinct message and step identities, so text from multiple agent loops is not merged into one message. + +### Interrupt and resume + +When a permission request, question, or option selection needs user input, the current run ends with: + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [] + } +} +``` + +The interrupt is persisted before it becomes visible to the client. The client collects answers, then starts a new request with the same `threadId`, a new `runId`, and `resume[]`. The resume stream belongs to this new request; it does not reconnect to the old stream. + +### Adapter state + +The adapter stores protocol mappings, idempotency data, and pending interrupts in one file per thread. This directory does not contain conversation text, LLM keys, or cloud credentials, and it is not a conversation export directory. + +## When to use AG-UI + +| Requirement | Recommended mode | +|-------------|------------------| +| Build a chat UI with live text, reasoning, tools, and steps | **AG-UI** | +| Handle permissions, questions, and option selection in a UI | **AG-UI** | +| Let another agent or orchestrator call iac-code directly | **A2A** | +| Integrate an IDE/editor with ACP sessions and terminal features | **ACP** | +| Operate iac-code manually | **Interactive REPL or Web/Desktop** | + +AG-UI and A2A can run at the same time. They expose separate HTTP endpoints while sharing the same iac-code execution implementation. + +## Current boundaries + +- The AG-UI transport is HTTP POST plus SSE. +- The A2A upstream must use a loopback address; the adapter refuses arbitrary remote A2A URLs. +- `cwd` is required per request and must be under an allowed workspace root. +- Client-defined `tools` are not currently accepted; iac-code owns the tool set. +- User messages support text and inline base64 images, not remote media URLs. +- If the client disconnects from an active SSE run before it reaches an interrupt, the adapter cancels the matching A2A task. +- The SSE stream sends a comment heartbeat every 15 seconds. Conforming clients ignore it. + +## Next steps + +- [Getting started](./getting-started.md) — Install, start, and connect a first client. +- [Protocol reference](./protocol-reference.md) — Request fields, events, interrupt/resume, persistence, and error semantics. diff --git a/website/docs/agui/protocol-reference.md b/website/docs/agui/protocol-reference.md new file mode 100644 index 00000000..ab8337b8 --- /dev/null +++ b/website/docs/agui/protocol-reference.md @@ -0,0 +1,287 @@ +--- +sidebar_position: 3 +title: Protocol Reference +description: Reference for iac-code AG-UI requests, events, interrupts, resume, cancellation, and persistence. +--- + +# AG-UI Protocol Reference + +This page describes the HTTP/SSE surface exposed by `iac-code agui` and the iac-code extension fields carried in standard AG-UI envelopes. See the [overview](./overview.md) and [getting started](./getting-started.md) pages first. + +## HTTP endpoints + +| Method and path | Purpose | +|-----------------|---------| +| `GET /health` | Health and protocol version information | +| `POST /` | Submit `RunAgentInput` and receive an SSE event stream | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | Namespaced cancellation extension | + +The `POST /` body must use JSON, and clients should request SSE: + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +When `IAC_CODE_AGUI_AUTH_TOKEN` is configured, protected requests also require: + +```http +Authorization: Bearer +``` + +Use the standard `Accept-Language` header as an error-message fallback. `forwardedProps.iacCode.preferredLanguage` takes precedence and is also forwarded to the A2A runtime. + +## RunAgentInput + +Minimal normal-run example: + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [ + {"id": "message-1", "role": "user", "content": "Create a VPC template"} + ], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +### Standard fields + +| Field | Requirement | iac-code behavior | +|-------|-------------|-------------------| +| `threadId` | Required non-empty string | Stable conversation identity mapped to one A2A context and iac-code session | +| `runId` | Required non-empty string | One HTTP/SSE run; cannot be reused within the thread | +| `parentRunId` | Optional | Copied to `RUN_STARTED` | +| `state` | Required | Kept in the standard envelope; not used as iac-code runtime state | +| `messages` | Required | A new run uses the latest user message; a resume need not add one | +| `tools` | Required and empty | Client-defined tools are not supported | +| `context` | Required | Kept in the envelope; not currently converted into prompt context | +| `forwardedProps` | Required | Must contain the `iacCode` extension | +| `resume` | For resume | One response for every pending interrupt | + +User messages support strings, `text` parts, and `image` parts with inline base64 `data` sources. Remote image URLs, audio, video, document, and generic binary parts are not supported. A decoded image is limited to 8 MiB, all images to 10 MiB, and the full HTTP request to 12 MiB. + +## `forwardedProps.iacCode` + +This object uses a strict schema; unknown fields are rejected. + +| Field | Type | Required | Meaning | +|-------|------|----------|---------| +| `schemaVersion` | `1` | Yes | iac-code extension version | +| `rosInvocationId` | string | Yes | Current execution caller identity, up to 256 characters | +| `cwd` | string | Yes | Absolute workspace path | +| `model` | string | No | Per-request model override | +| `llmApiKey` | string | No | Per-request LLM provider key | +| `thinking.enabled` | boolean | No | Request thinking output | +| `thinking.effort` | string | No | Provider-specific thinking effort | +| `thinking.budget` | positive integer | No | Provider-specific thinking budget | +| `userId` | string | No | Telemetry and caller-binding identity | +| `channel` | string | No | Caller channel metadata | +| `preferredLanguage` | string | No | Request-local user-facing language, such as `en` | +| `candidatePresentation` | `standard` or `rich` | No | Pipeline candidate presentation | +| `runMode` | `normal` or `pipeline` | No | Execution mode; otherwise chosen by A2A | +| `pipelineName` | string | No | Pipeline name, for example `selling` | +| `cleanupOnly` | boolean | No | Request a Pipeline cleanup-only path | +| `alibabaCloud.accessKeyId` | string | No | Request-local AccessKey ID | +| `alibabaCloud.accessKeySecret` | string | No | Request-local AccessKey secret | +| `alibabaCloud.securityToken` | string | No | Request-local STS token | +| `alibabaCloud.regionId` | string | No | Request-local default region | + +The initial run and its interrupt resumes must retain the same `rosInvocationId`. A later normal turn may use a new value. Cancellation must use the current execution's value. + +A `threadId` is bound to the first request's `cwd` and `userId`; later requests cannot move the same thread to another workspace or caller. + +## SSE and heartbeat + +Each AG-UI event is emitted as an SSE `data:` record. After 15 seconds without an event, the server emits: + +```text +: heartbeat +``` + +This is an SSE comment, not an AG-UI `CUSTOM` event. Conforming clients ignore it while it keeps the HTTP connection active. + +## Standard event mapping + +| A2A/iac-code signal | AG-UI output | +|---------------------|--------------| +| Accepted request | `RUN_STARTED` | +| Agent text | `TEXT_MESSAGE_START/CONTENT/END` | +| Raw thinking | `REASONING_START`, `REASONING_MESSAGE_*`, `REASONING_END` | +| Tool start and arguments | `TOOL_CALL_START/ARGS/END` | +| Tool result | `TOOL_CALL_RESULT` | +| Pipeline step lifecycle | `STEP_STARTED/STEP_FINISHED` | +| Pipeline recovery snapshot | `ACTIVITY_SNAPSHOT` | +| Normal completion | `RUN_FINISHED` with `outcome.type = "success"` | +| User input required | `RUN_FINISHED` with `outcome.type = "interrupt"` | +| Adapter or A2A error | `RUN_ERROR` | + +`RUN_FINISHED` ends one AG-UI run, not necessarily the whole Pipeline. A Pipeline interrupted several times has several runs, each with its own `RUN_STARTED` and `RUN_FINISHED`. Pipeline business completion is represented by `pipeline_completed`, `pipeline_error`, and related Pipeline events. + +To keep AG-UI spans balanced, the adapter closes open message, reasoning, tool, and step spans before an interrupt ends a run. The resume run reopens any durable Pipeline step still active. Raw event review may therefore show the same business step closing in one run and reopening in the next; this is not reversed execution. + +## iac-code custom events + +### `iac-code.session.v1` + +Exposes the current adapter-to-A2A mapping, including `threadId`, `aguiRunId`, `executionId`, `contextId`, `taskId`, `rosInvocationId`, and `sessionId`. Use `executionId` with the cancellation extension. Generic clients may safely ignore this event. + +### `iac-code.artifact.v1` + +Carries a structured projection of an A2A task artifact for optional preview, download, or diagnostics. + +### `iac-code.tool-progress.v1` + +Carries intermediate tool progress without a standard equivalent. Tool start, arguments, and final result remain standard `TOOL_CALL_*` events and are not duplicated here. + +### `iac-code.pipeline.v1` + +Only useful Pipeline information without a complete standard equivalent is emitted. Current `eventType` values are: + +- Pipeline: `pipeline_started`, `pipeline_resumed`, `pipeline_completed`, `pipeline_error`, `pipeline_warning`, `backup_blocked`; +- candidates: `candidate_started`, `candidate_completed`, `candidate_failed`, `candidate_interrupted`, `candidate_restart_requested`, `candidate_selected`, `candidate_detail_shown`, `candidate_step_failed`; +- sub-pipelines and step errors: `sub_pipeline_started`, `sub_pipeline_completed`, `sub_step_failed`, `step_failed`; +- stacks and cleanup: `stack_progress`, `stack_instances_progress`, `stack_current_changed`, `cleanup_started`, `cleanup_progress`, `cleanup_completed`, `cleanup_failed`; +- rollback: `rollback_triggered`, `rollback_completed`; +- context: `context_compaction_started`, `context_compacted`, `context_compaction_failed`, `fields_marked_stale`; +- presentation and tools: `diagram_shown`, `mcp_status`, `tool_progress`. + +Signals with standard mappings are not duplicated as `CUSTOM`: `text_delta` becomes `TEXT_MESSAGE_*`, `thinking_delta` becomes `REASONING_*`, `tool_started/tool_result` become `TOOL_CALL_*`, `usage` becomes `RUN_FINISHED.usage`, and step lifecycles become `STEP_*`. + +Clients should deduplicate replayed Pipeline events with `(name, value.eventId)` or the Pipeline sequence and tolerate unknown namespaced custom events. + +## Interrupt + +An input-required run ends with `RUN_FINISHED.outcome.type = "interrupt"`. Each interrupt includes: + +- `id` and `reason`; +- a user-facing `message`; +- an optional `toolCallId`; +- a JSON `responseSchema`; +- `expiresAt`; +- metadata such as `title`, `purpose`, `safeSummary`, `options`, and `toolName`. + +For a permission request, the response schema typically accepts: + +```json +{"decision": "allow_once"} +``` + +or: + +```json +{"decision": "deny"} +``` + +Render `message`, `responseSchema`, and descriptive metadata instead of inferring the UI from `reason` alone. Questions and option selection may use different schemas. + +## Resume + +A resume is a new `POST /` with the same `threadId`, a new `runId`, the same `rosInvocationId`, and one entry per pending interrupt: + +```json +{ + "resume": [ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + } + ] +} +``` + +Rules: + +- every pending interrupt must be answered exactly once; +- duplicate and unknown IDs are rejected; +- `resolved` requires a payload matching the corresponding schema; +- `cancelled` stops that interrupt, and maps to `deny` for permissions; +- durable pending state is removed only after A2A accepts the response; +- schema errors produce `RUN_ERROR` while leaving the interrupt retryable; +- repeated accepted responses do not execute the tool again. + +Before applying a resume, the adapter can ask A2A to restore the iac-code session, verifies the A2A task/context identity, and catches up missing Pipeline events. + +## Turns and identities + +```text +threadId (stable conversation) + ├─ runId-1 (user turn) + ├─ runId-2 (interrupt resume) + ├─ runId-3 (another resume) + └─ runId-4 (next normal message) +``` + +Every HTTP/SSE request uses a unique `runId`. Interrupt resume is a new run. After a normal turn completes, the next message creates a new execution while reusing the thread's iac-code session. Run idempotency is scoped to `(threadId, runId)`. + +## Cancellation extension + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +Possible outcomes are `cancelled`, `already_terminal`, or HTTP `404` with `EXECUTION_NOT_FOUND`. Cancellation clears pending interrupts and does not change standard AG-UI event formats. + +## Persistence and recovery + +Adapter state defaults to: + +```text +/agui/threads/.json +``` + +Each file contains thread/context/workspace binding, session and task identity, execution identity, Pipeline recovery positions, pending interrupts, and run/resume idempotency data. The adapter lazily loads one requested thread and atomically replaces only that thread's small file. + +It never stores LLM keys, AccessKey secrets, or STS tokens. This is an adapter mapping directory, not a store for conversation text or execution artifacts. A2A manages its own session and task persistence; see the [A2A documentation](../a2a/overview.md). + +An expired interrupt is rejected on the next access, its pending state is cleared, and the adapter attempts to cancel the matching A2A task. + +## Disconnections + +- A run safely finished with an interrupt no longer depends on its SSE connection. +- Resume creates a new SSE connection. +- Disconnecting an ordinary active run causes the adapter to cancel the A2A task. +- Disconnecting after an interrupt does not delete its persisted recovery state. + +## Errors + +Errors before SSE begins use an HTTP JSON envelope. Errors during execution use standard `RUN_ERROR` events. Common codes include: + +| Code | Meaning | +|------|---------| +| `INVALID_INPUT` | Invalid envelope, extension fields, message content, or workspace | +| `DUPLICATE_RUN_ID` | The same request digest used an existing run ID | +| `RUN_ID_CONFLICT` | A different request reused an existing run ID | +| `THREAD_BUSY` | The thread already has an active run | +| `THREAD_BINDING_CONFLICT` | The thread's workspace or caller conflicts with its binding | +| `RESUME_REQUIRED` | The thread is waiting for interrupt responses | +| `INCOMPLETE_RESUME` | Missing pending interrupts or duplicate IDs | +| `UNKNOWN_INTERRUPT` | Resume references an unknown interrupt | +| `RESUME_PAYLOAD_INVALID` | Missing payload or schema mismatch | +| `RESUME_ALREADY_APPLIED` | The response was already applied or conflicts with it | +| `EXECUTION_EXPIRED` | The interrupt expired | +| `EXECUTION_LOST` | Adapter, A2A task, or iac-code session could not be recovered | +| `STATE_PERSISTENCE_FAILED` | Recovery-critical state could not be committed | +| `A2A_UNAVAILABLE` | The local A2A execution service is unavailable | +| `A2A_PROTOCOL_ERROR` | A2A task/context/session identity conflicts with the mapping | +| `A2A_EXECUTION_FAILED` | The A2A task ended in failure | +| `CANCELLED` | The execution was cancelled | + +Recovery-critical writes fail closed. The adapter does not announce a recoverable task, session, or interrupt before its mapping is durable, and cancels the matching A2A task when necessary. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current.json b/website/i18n/de/docusaurus-plugin-content-docs/current.json index 1b38f3ad..ff71397b 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/de/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "A2A-Protokoll", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "AG-UI-Protokoll", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "Automatisierung", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..1ec38116 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,259 @@ +--- +sidebar_position: 2 +title: Erste Schritte +description: Den AG-UI-Adapter von iac-code installieren, starten und aufrufen. +--- + +# Erste Schritte mit AG-UI + +## Voraussetzungen + +1. Python 3.10 oder neuer ist installiert. +2. Ein LLM-Anbieter ist für iac-code konfiguriert. Siehe [Authentifizierung](../configuration/authentication.md). +3. Für Zugriffe auf Alibaba Cloud sind Cloud-Anmeldedaten konfiguriert oder temporäre Anmeldedaten werden pro Anfrage übergeben. +4. Ein absoluter Arbeitsbereichspfad steht iac-code zum Lesen und Schreiben zur Verfügung. + +Installieren Sie die AG-UI-Abhängigkeiten: + +```bash +pip install "iac-code[agui]" +``` + +Für die Entwicklung aus dem Quellrepository: + +```bash +uv sync --extra agui +``` + +## Option 1: Verwalteten lokalen A2A-Kern starten + +Lassen Sie für die einfachste lokale Konfiguration `--a2a-url` weg: + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +Der Adapter wählt einen freien Loopback-Port, startet einen verwalteten Kindprozess `iac-code a2a` und beendet ihn zusammen mit dem Adapter. Der Kindprozess übernimmt die aktuelle iac-code-Konfiguration und Laufzeitumgebung. + +Dieser Modus eignet sich für lokale Entwicklung und einen gemeinsamen Prozesslebenszyklus. Verwenden Sie die nächste Option, wenn beide Dienste in der Produktion unabhängig überwacht werden sollen. + +## Option 2: Mit einem unabhängigen A2A-Kern verbinden + +Starten Sie zuerst den A2A-Server: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +Starten Sie dann den AG-UI-Adapter: + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +Die Dienste behalten getrennte Aufgaben und Ports. A2A kann weiterhin A2A-Clients bedienen, während der Adapter nur über die Loopback-Schnittstelle darauf zugreift. + +Mit `--thinking-exposure all` wandelt der Adapter rohe Reasoning-Inhalte in standardisierte `REASONING_*`-Ereignisse um. Aktivieren Sie dies nur für vertrauenswürdige Clients. Verwenden Sie den A2A-Standard `tool-trace`, wenn Reasoning-Inhalte nicht offengelegt werden sollen. + +Bei einem Bearer-Token für A2A: + +```bash +export IACCODE_A2A_HTTP_TOKEN="lokales-a2a-geheimnis" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Übergeben Sie dem Adapter dasselbe Upstream-Token: + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="lokales-a2a-geheimnis" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## YAML-Konfiguration + +Statische Starteinstellungen können in YAML gespeichert werden: + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +Starten Sie den Adapter mit: + +```bash +iac-code agui --config agui-server.yml +``` + +Explizite CLI-Argumente überschreiben YAML. Übergeben Sie sensible Werte wie Tokens über Umgebungsvariablen, statt sie in der Konfigurationsdatei zu speichern. + +| CLI / YAML | Standard | Bedeutung | +|------------|----------|-----------| +| `--host` / `host` | `127.0.0.1` | HTTP-Bindeadresse von AG-UI | +| `--port` / `port` | `8000` | AG-UI-HTTP-Port; Bereitstellungsbeispiele verwenden `41243` | +| `--a2a-url` / `a2a-url` | leer | Lokale A2A-URL; leer startet einen verwalteten Kindprozess | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Sekunden, in denen eine Unterbrechung wiederaufgenommen werden kann | +| `--state-dir` / `state-dir` | `/agui` | Verzeichnis für AG-UI-Threadstatus | +| `--idle-shutdown` / `idle-shutdown` | `0` | Leerlaufabschaltung; `0` deaktiviert sie | +| `--debug` / `debug` | `false` | Debug-Protokollierung | +| `--log-stdout` / `log-stdout` | `false` | Protokolle zusätzlich auf stdout ausgeben | + +Zugehörige Umgebungsvariablen: + +| Variable | Zweck | +|----------|-------| +| `IAC_CODE_AGUI_HOST` | AG-UI-Bindeadresse | +| `IAC_CODE_AGUI_PORT` | AG-UI-Port | +| `IAC_CODE_AGUI_A2A_URL` | Lokale A2A-Upstream-URL | +| `IAC_CODE_AGUI_A2A_TOKEN` | Bearer-Token für den A2A-Upstream | +| `IAC_CODE_AGUI_AUTH_TOKEN` | Bearer-Token zum Schutz des AG-UI-Endpunkts | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Lebensdauer einer Unterbrechung | +| `IAC_CODE_AGUI_STATE_DIR` | Verzeichnis für AG-UI-Threadstatus | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | Erlaubte Arbeitsbereichswurzeln, getrennt mit dem Pfadtrenner des Betriebssystems | +| `IAC_CODE_CONFIG_DIR` | iac-code-Konfigurationswurzel und übergeordnetes Standardverzeichnis für AG-UI-Status | + +## Zustandsprüfung + +```bash +curl http://127.0.0.1:41243/health +``` + +Beispielantwort: + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "aktuelle iac-code-Version" +} +``` + +## Offiziellen JavaScript-Client verwenden + +Installieren Sie die geprüfte Clientversion: + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +Das Beispiel verbindet sich mit `iac-code agui`, verwendet den standardisierten `HttpAgent` und übergibt iac-code-Laufzeitwerte in `forwardedProps`: + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const threadId = randomUUID(); +const rosInvocationId = randomUUID(); +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId, + // Wenn IAC_CODE_AGUI_AUTH_TOKEN konfiguriert ist: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId, + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "de", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "Erstelle eine VPC-Vorlage mit zwei vSwitches.", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[Werkzeug] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[Schritt] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +Bei einem Bearer-Token übergeben Sie `Authorization` über `HttpAgent.headers`. Browseranwendungen verbinden sich normalerweise über ein Same-Origin-Backend oder einen Reverse Proxy; der Adapter fügt keine CORS-Richtlinie hinzu. + +## Unterbrechungen verarbeiten + +Der offizielle Client speichert `RUN_FINISHED.outcome.interrupts` in `agent.pendingInterrupts`. Erstellen Sie jede Antwort anhand ihres `responseSchema` und senden Sie sie in einem neuen Lauf: + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +Dieser Payload gilt nur für Berechtigungsunterbrechungen, deren Schema `decision` verlangt. Fragen und Optionsauswahlen besitzen eigene Schemata. + +Eine Wiederaufnahme muss die ursprüngliche `threadId`, eine neue `runId` und die `rosInvocationId` der unterbrochenen Ausführung verwenden. Sie muss alle aktuell ausstehenden Unterbrechungen in einer Anfrage beantworten und für `resolved` das jeweilige `responseSchema` erfüllen. Verwenden Sie `status: "cancelled"`, wenn der Benutzer nicht fortfahren möchte. + +## Pipeline starten + +Setzen Sie `runMode` auf `pipeline` und wählen Sie optional eine Pipeline: + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +Clients sollten `STEP_*`, `TOOL_CALL_*`, `ACTIVITY_SNAPSHOT` und `CUSTOM` verarbeiten. Ein allgemeiner Client kann unbekannte iac-code-Custom-Ereignisse ignorieren und trotzdem alle Standardereignisse normal verarbeiten. + +## Arbeitsbereich und temporäre Anmeldedaten + +`cwd` wird nicht beim Serverstart festgelegt. Jede Anfrage muss einen absoluten Pfad unter einer durch `IAC_CODE_AGUI_ALLOWED_CWDS` oder `IACCODE_A2A_ALLOWED_CWDS` erlaubten Wurzel enthalten. + +Der Aufrufer kann Modell, LLM-Schlüssel und temporäre Alibaba-Cloud-Anmeldedaten über `forwardedProps.iacCode` pro Anfrage übergeben. Der Adapter schreibt diese Geheimnisse nicht in seine Threadstatusdatei, sondern leitet sie nach den üblichen A2A-Regeln an den Ausführungskern weiter. + +## Statusverzeichnis + +Standardaufbau: + +```text +/agui/ + threads/ + .json +``` + +Jeder Thread wird unabhängig geschrieben; beim Start werden historische Threads nicht durchsucht. Normale UUIDs bleiben lesbar. Unsichere IDs werden kodiert, besonders lange IDs verwenden einen Dateischlüssel fester Länge. Das JSON-Dokument speichert und prüft stets die ursprüngliche `threadId`. + +Das Verzeichnis enthält nur Adapterzuordnungen, Unterbrechungen und Idempotenzstatus, aber keine Gesprächsinhalte oder Anmeldedaten. Bearbeiten Sie die JSON-Dateien nicht manuell. + +## Nächste Schritte + +- [AG-UI-Überblick](./overview.md) +- [Protokollreferenz](./protocol-reference.md) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..a76f4e70 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,135 @@ +--- +sidebar_position: 1 +title: AG-UI-Protokoll +description: Architektur, Funktionen und Einsatzbereiche der AG-UI-Integration von iac-code. +--- + +# AG-UI-Protokoll + +## Was ist AG-UI? + +Das [Agent-User Interaction Protocol (AG-UI)](https://docs.ag-ui.com/concepts/architecture) ist ein Ereignisstrom-Protokoll zwischen Agenten und Benutzeranwendungen. Ein Client startet mit `RunAgentInput` einen Lauf und empfängt über HTTP Server-Sent Events (SSE) strukturierte Ereignisse für Text, Reasoning, Werkzeugaufrufe, Schritte, Status und Unterbrechungen. + +AG-UI eignet sich für Webkonsolen, Chat-Clients, IDE-Erweiterungen und andere Anwendungen, die eine Agentenausführung in Echtzeit darstellen. Statt nur den Abschlusstext zu verarbeiten, kann ein Client Modellausgaben, Werkzeugargumente und -ergebnisse, Pipeline-Schritte sowie ausstehende Bestätigungen getrennt anzeigen. + +## Architektur von iac-code + +iac-code verwendet einen **A2A-Ausführungskern mit einem AG-UI-Protokolladapter**: + +```text +AG-UI-Client + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Agentenschleife / Pipeline / LLM / Alibaba-Cloud-API +``` + +`iac-code a2a` ist der einzige Ausführungskern. Er verwaltet: + +- normale Unterhaltungen und Pipeline-Ausführungen; +- iac-code-Sitzungen sowie A2A-Kontexte und -Tasks; +- Werkzeugberechtigungen, Fragen, Optionsauswahl und Wiederaufnahme; +- Lebenszyklus und Abbruch von Ausführungen; +- Aufrufe von LLMs und Alibaba-Cloud-APIs. + +`iac-code agui` erzeugt keine zweite Agent-Runtime und führt Pipelines nicht selbst aus. Der Adapter: + +- wandelt AG-UI-`RunAgentInput` in A2A-Anfragen um; +- bildet A2A-Ereignisse auf standardisierte AG-UI-Ereignisse ab; +- ordnet `threadId/runId` den A2A-Werten `contextId/taskId` zu; +- wandelt AG-UI-`resume[]` in eine A2A-Eingabewiederaufnahme um; +- persistiert Protokollzuordnungen und ausstehende Unterbrechungen; +- leitet Abbrüche an A2A weiter. + +AG-UI und A2A besitzen daher keine getrennte Ausführungssemantik. Modellauswahl, Cloud-Anmeldedaten, Berechtigungsregeln und Pipeline-Verhalten werden von derselben A2A-Runtime umgesetzt. + +## Standardprotokoll und iac-code-Erweiterungen + +Der externe Strom verwendet standardisierte AG-UI-Ereignisse: + +- `RUN_STARTED`, `RUN_FINISHED` und `RUN_ERROR`; +- `TEXT_MESSAGE_*`; +- `REASONING_*`; +- `TOOL_CALL_*`; +- `STEP_STARTED` und `STEP_FINISHED`; +- `ACTIVITY_SNAPSHOT`. + +Nur nützliche Pipeline-Informationen ohne standardisierte Entsprechung erscheinen als namensraumgebundene `CUSTOM`-Ereignisse. Ein allgemeiner AG-UI-Client darf sie ignorieren, ohne Text, Werkzeugaufrufe, Unterbrechungen oder den Laufzyklus zu beeinträchtigen. + +Anfragen bleiben standardisierte `RunAgentInput`-Umschläge. iac-code nutzt `forwardedProps` für Arbeitsbereich, Laufmodus und weitere erforderliche Laufzeitdaten: + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "anfrage-identitaet", + "cwd": "/absoluter/arbeitsbereich/pfad", + "runMode": "normal" + } + } +} +``` + +Ein allgemeiner Client kann die standardisierten Ereignisse von iac-code direkt verarbeiten. Bei einem direkten Aufruf von `iac-code agui` muss er jedoch Laufzeitfelder wie `cwd` unter `forwardedProps.iacCode` bereitstellen. + +## Unterstützte Interaktionen + +### Normale Unterhaltungen mit mehreren Dialogrunden + +Verwenden Sie für die Unterhaltung dieselbe `threadId` und für jede Benutzerrunde eine neue `runId`. Der Adapter bindet den Thread an eine iac-code-Sitzung. Nach Abschluss einer Runde startet die nächste Nachricht eine neue HTTP/SSE-Anfrage; sie setzt niemals eine bereits abgeschlossene SSE-Antwort fort. + +### Pipeline + +Setzen Sie `forwardedProps.iacCode.runMode` auf `pipeline`. Der A2A-Kern führt die Pipeline weiterhin aus. Hauptschritte werden zu `STEP_*`-Ereignissen; Text, Reasoning und Werkzeuge verwenden ihre jeweiligen Standardereignisse. Kandidateninformationen, Stack- und Bereinigungsfortschritt ohne Standardentsprechung werden über `iac-code.pipeline.v1` gesendet. + +Parallele Sub-Pipelines verwenden getrennte Nachrichten- und Schrittidentitäten, sodass Texte mehrerer Agentenschleifen nicht zusammengeführt werden. + +### Unterbrechung und Wiederaufnahme + +Wenn eine Berechtigung, Frage oder Auswahl eine Benutzereingabe benötigt, endet der aktuelle Lauf mit: + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [] + } +} +``` + +Die Unterbrechung wird persistiert, bevor sie für den Client sichtbar wird. Anschließend startet der Client eine neue Anfrage mit derselben `threadId`, einer neuen `runId` und `resume[]`. Der Wiederaufnahmestrom gehört zu dieser neuen Anfrage und verbindet sich nicht erneut mit dem alten Strom. + +### Adapterstatus + +Der Adapter speichert Protokollzuordnungen, Idempotenzdaten und ausstehende Unterbrechungen in einer Datei pro Thread. Das Verzeichnis enthält weder Gesprächstexte noch LLM-Schlüssel oder Cloud-Anmeldedaten und ist kein Exportverzeichnis für Unterhaltungen. + +## Wann sollte AG-UI verwendet werden? + +| Anforderung | Empfohlener Modus | +|-------------|-------------------| +| Chat-Oberfläche mit Live-Text, Reasoning, Werkzeugen und Schritten | **AG-UI** | +| Berechtigungen, Fragen und Optionsauswahl in einer Oberfläche | **AG-UI** | +| Direkter Aufruf von iac-code durch einen Agenten oder Orchestrator | **A2A** | +| IDE-/Editor-Integration mit ACP-Sitzungen und Terminalfunktionen | **ACP** | +| Manuelle Bedienung von iac-code | **Interaktive REPL oder Web/Desktop** | + +AG-UI und A2A können gleichzeitig laufen. Sie stellen getrennte HTTP-Endpunkte bereit, verwenden aber dieselbe Ausführungsimplementierung von iac-code. + +## Aktuelle Grenzen + +- Der AG-UI-Transport verwendet HTTP POST und SSE. +- Der vorgelagerte A2A-Dienst muss eine Loopback-Adresse verwenden; beliebige entfernte A2A-URLs werden abgelehnt. +- `cwd` ist pro Anfrage erforderlich und muss unterhalb eines erlaubten Arbeitsbereichsstamms liegen. +- Vom Client definierte `tools` werden derzeit nicht akzeptiert; iac-code verwaltet den Werkzeugsatz. +- Benutzernachrichten unterstützen Text und eingebettete Base64-Bilder, aber keine entfernten Medien-URLs. +- Trennt sich ein Client vor einer Unterbrechung von einem aktiven SSE-Lauf, bricht der Adapter den passenden A2A-Task ab. +- Der SSE-Strom sendet alle 15 Sekunden einen Heartbeat-Kommentar. Konforme Clients ignorieren ihn. + +## Nächste Schritte + +- [Erste Schritte](./getting-started.md) — Installation, Start und Verbindung des ersten Clients. +- [Protokollreferenz](./protocol-reference.md) — Anfragefelder, Ereignisse, Unterbrechung/Wiederaufnahme, Persistenz und Fehler. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..7a9a01e1 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,287 @@ +--- +sidebar_position: 3 +title: Protokollreferenz +description: Referenz zu AG-UI-Anfragen, Ereignissen, Unterbrechungen, Wiederaufnahme, Abbruch und Persistenz in iac-code. +--- + +# AG-UI-Protokollreferenz + +Diese Seite beschreibt die von `iac-code agui` bereitgestellte HTTP/SSE-Schnittstelle und die iac-code-Erweiterungsfelder in standardisierten AG-UI-Umschlägen. Lesen Sie zuerst den [Überblick](./overview.md) und die [Ersten Schritte](./getting-started.md). + +## HTTP-Endpunkte + +| Methode und Pfad | Zweck | +|------------------|-------| +| `GET /health` | Dienststatus und Protokollversionen | +| `POST /` | `RunAgentInput` senden und SSE-Ereignisstrom empfangen | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | Namensraumgebundene Abbrucherweiterung | + +Der Body von `POST /` muss JSON verwenden; Clients sollten SSE anfordern: + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +Bei konfiguriertem `IAC_CODE_AGUI_AUTH_TOKEN` ist außerdem erforderlich: + +```http +Authorization: Bearer +``` + +Der Standardheader `Accept-Language` dient als Rückfall für Fehlermeldungen. `forwardedProps.iacCode.preferredLanguage` hat Vorrang und wird an die A2A-Runtime weitergeleitet. + +## RunAgentInput + +Minimales Beispiel für einen normalen Lauf: + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [ + {"id": "message-1", "role": "user", "content": "Erstelle eine VPC-Vorlage"} + ], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +### Standardfelder + +| Feld | Anforderung | Verhalten von iac-code | +|------|-------------|-------------------------| +| `threadId` | Erforderliche, nicht leere Zeichenfolge | Stabile Gesprächsidentität für einen A2A-Kontext und eine iac-code-Sitzung | +| `runId` | Erforderliche, nicht leere Zeichenfolge | Ein HTTP/SSE-Lauf; darf im Thread nicht wiederverwendet werden | +| `parentRunId` | Optional | Wird nach `RUN_STARTED` kopiert | +| `state` | Erforderlich | Bleibt im Standardumschlag, wird aber nicht als iac-code-Laufzeitstatus genutzt | +| `messages` | Erforderlich | Neuer Lauf verwendet die letzte Benutzernachricht; eine Wiederaufnahme benötigt keine neue | +| `tools` | Erforderlich und leer | Clientdefinierte Werkzeuge werden nicht unterstützt | +| `context` | Erforderlich | Bleibt im Umschlag, wird derzeit nicht in Prompt-Kontext umgewandelt | +| `forwardedProps` | Erforderlich | Muss die Erweiterung `iacCode` enthalten | +| `resume` | Bei Wiederaufnahme | Eine Antwort für jede ausstehende Unterbrechung | + +Benutzernachrichten unterstützen Zeichenfolgen sowie `text`- und `image`-Teile mit eingebetteten Base64-`data`-Quellen. Entfernte Bild-URLs, Audio, Video, Dokumente und allgemeine Binärteile werden nicht unterstützt. Ein dekodiertes Bild ist auf 8 MiB, alle Bilder zusammen auf 10 MiB und die gesamte HTTP-Anfrage auf 12 MiB begrenzt. + +## `forwardedProps.iacCode` + +Das Schema ist strikt; unbekannte Felder werden abgelehnt. + +| Feld | Typ | Erforderlich | Bedeutung | +|------|-----|--------------|-----------| +| `schemaVersion` | `1` | Ja | Version der iac-code-Erweiterung | +| `rosInvocationId` | Zeichenfolge | Ja | Aufruferidentität der aktuellen Ausführung, maximal 256 Zeichen | +| `cwd` | Zeichenfolge | Ja | Absoluter Arbeitsbereichspfad | +| `model` | Zeichenfolge | Nein | Modellüberschreibung pro Anfrage | +| `llmApiKey` | Zeichenfolge | Nein | LLM-Anbieterschlüssel pro Anfrage | +| `thinking.enabled` | boolesch | Nein | Reasoning-Ausgabe anfordern | +| `thinking.effort` | Zeichenfolge | Nein | Anbieterspezifischer Reasoning-Aufwand | +| `thinking.budget` | positive Ganzzahl | Nein | Anbieterspezifisches Reasoning-Budget | +| `userId` | Zeichenfolge | Nein | Identität für Telemetrie und Aufruferbindung | +| `channel` | Zeichenfolge | Nein | Metadaten des Aufruferkanals | +| `preferredLanguage` | Zeichenfolge | Nein | Anfragelokale Anzeigesprache, etwa `de` | +| `candidatePresentation` | `standard` oder `rich` | Nein | Darstellung von Pipeline-Kandidaten | +| `runMode` | `normal` oder `pipeline` | Nein | Ausführungsmodus, andernfalls durch A2A gewählt | +| `pipelineName` | Zeichenfolge | Nein | Pipeline-Name, zum Beispiel `selling` | +| `cleanupOnly` | boolesch | Nein | Nur Pipeline-Bereinigung anfordern | +| `alibabaCloud.accessKeyId` | Zeichenfolge | Nein | Anfragebezogene AccessKey-ID | +| `alibabaCloud.accessKeySecret` | Zeichenfolge | Nein | Anfragebezogenes AccessKey-Secret | +| `alibabaCloud.securityToken` | Zeichenfolge | Nein | Anfragebezogenes STS-Token | +| `alibabaCloud.regionId` | Zeichenfolge | Nein | Anfragebezogene Standardregion | + +Der erste Lauf und seine Wiederaufnahmen müssen dieselbe `rosInvocationId` behalten. Eine spätere normale Runde darf einen neuen Wert verwenden. Beim Abbruch ist der Wert der aktuellen Ausführung erforderlich. + +Eine `threadId` wird an `cwd` und `userId` der ersten Anfrage gebunden; spätere Anfragen können denselben Thread nicht in einen anderen Arbeitsbereich oder zu einem anderen Aufrufer verschieben. + +## SSE und Heartbeat + +Jedes AG-UI-Ereignis wird als SSE-`data:`-Datensatz gesendet. Nach 15 Sekunden ohne Ereignis sendet der Server: + +```text +: heartbeat +``` + +Dies ist ein SSE-Kommentar, kein AG-UI-`CUSTOM`-Ereignis. Konforme Clients ignorieren ihn; die HTTP-Verbindung bleibt dadurch aktiv. + +## Standardereignis-Zuordnung + +| A2A/iac-code-Signal | AG-UI-Ausgabe | +|---------------------|---------------| +| Anfrage angenommen | `RUN_STARTED` | +| Agententext | `TEXT_MESSAGE_START/CONTENT/END` | +| Rohes Reasoning | `REASONING_START`, `REASONING_MESSAGE_*`, `REASONING_END` | +| Werkzeugstart und Argumente | `TOOL_CALL_START/ARGS/END` | +| Werkzeugergebnis | `TOOL_CALL_RESULT` | +| Pipeline-Schrittzyklus | `STEP_STARTED/STEP_FINISHED` | +| Pipeline-Wiederaufnahmeabbild | `ACTIVITY_SNAPSHOT` | +| Normaler Abschluss | `RUN_FINISHED` mit `outcome.type = "success"` | +| Benutzereingabe erforderlich | `RUN_FINISHED` mit `outcome.type = "interrupt"` | +| Adapter- oder A2A-Fehler | `RUN_ERROR` | + +`RUN_FINISHED` beendet einen AG-UI-Lauf, nicht zwingend die gesamte Pipeline. Eine mehrfach unterbrochene Pipeline besitzt mehrere Läufe mit jeweils eigenem `RUN_STARTED` und `RUN_FINISHED`. Der fachliche Pipeline-Abschluss wird durch `pipeline_completed`, `pipeline_error` und verwandte Ereignisse dargestellt. + +Für ausgeglichene AG-UI-Spans schließt der Adapter vor einer Unterbrechung offene Nachrichten-, Reasoning-, Werkzeug- und Schritt-Spans. Der Wiederaufnahmelauf öffnet weiterhin aktive, dauerhafte Pipeline-Schritte erneut. In Rohereignissen kann derselbe fachliche Schritt daher in einem Lauf geschlossen und im nächsten wieder geöffnet werden; die Ausführung läuft nicht rückwärts. + +## Benutzerdefinierte iac-code-Ereignisse + +### `iac-code.session.v1` + +Stellt die aktuelle Adapter-A2A-Zuordnung bereit, einschließlich `threadId`, `aguiRunId`, `executionId`, `contextId`, `taskId`, `rosInvocationId` und `sessionId`. Verwenden Sie `executionId` für die Abbrucherweiterung. Allgemeine Clients dürfen dieses Ereignis ignorieren. + +### `iac-code.artifact.v1` + +Enthält eine strukturierte Projektion eines A2A-Task-Artefakts für optionale Vorschau, Download oder Diagnose. + +### `iac-code.tool-progress.v1` + +Enthält Werkzeug-Zwischenfortschritt ohne Standardentsprechung. Start, Argumente und Endergebnis bleiben standardisierte `TOOL_CALL_*`-Ereignisse und werden hier nicht dupliziert. + +### `iac-code.pipeline.v1` + +Nur nützliche Pipeline-Informationen ohne vollständige Standardentsprechung werden gesendet. Aktuelle `eventType`-Werte: + +- Pipeline: `pipeline_started`, `pipeline_resumed`, `pipeline_completed`, `pipeline_error`, `pipeline_warning`, `backup_blocked`; +- Kandidaten: `candidate_started`, `candidate_completed`, `candidate_failed`, `candidate_interrupted`, `candidate_restart_requested`, `candidate_selected`, `candidate_detail_shown`, `candidate_step_failed`; +- Sub-Pipelines und Schrittfehler: `sub_pipeline_started`, `sub_pipeline_completed`, `sub_step_failed`, `step_failed`; +- Stacks und Bereinigung: `stack_progress`, `stack_instances_progress`, `stack_current_changed`, `cleanup_started`, `cleanup_progress`, `cleanup_completed`, `cleanup_failed`; +- Rollback: `rollback_triggered`, `rollback_completed`; +- Kontext: `context_compaction_started`, `context_compacted`, `context_compaction_failed`, `fields_marked_stale`; +- Darstellung und Werkzeuge: `diagram_shown`, `mcp_status`, `tool_progress`. + +Signale mit Standardzuordnung werden nicht als `CUSTOM` dupliziert: `text_delta` wird zu `TEXT_MESSAGE_*`, `thinking_delta` zu `REASONING_*`, `tool_started/tool_result` zu `TOOL_CALL_*`, `usage` zu `RUN_FINISHED.usage` und Schrittzyklen zu `STEP_*`. + +Clients sollten wiederholte Pipeline-Ereignisse mit `(name, value.eventId)` oder der Pipeline-Sequenz deduplizieren und unbekannte namensraumgebundene Ereignisse tolerieren. + +## Unterbrechung + +Ein Lauf mit erforderlicher Eingabe endet mit `RUN_FINISHED.outcome.type = "interrupt"`. Jede Unterbrechung enthält: + +- `id` und `reason`; +- eine benutzerorientierte `message`; +- eine optionale `toolCallId`; +- ein JSON-`responseSchema`; +- `expiresAt`; +- Metadaten wie `title`, `purpose`, `safeSummary`, `options` und `toolName`. + +Für eine Berechtigungsanfrage akzeptiert das Schema normalerweise: + +```json +{"decision": "allow_once"} +``` + +oder: + +```json +{"decision": "deny"} +``` + +Stellen Sie `message`, `responseSchema` und beschreibende Metadaten dar, statt die Oberfläche nur aus `reason` abzuleiten. Fragen und Optionsauswahlen können andere Schemata verwenden. + +## Wiederaufnahme + +Eine Wiederaufnahme ist ein neues `POST /` mit derselben `threadId`, einer neuen `runId`, derselben `rosInvocationId` und einem Eintrag pro ausstehender Unterbrechung: + +```json +{ + "resume": [ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + } + ] +} +``` + +Regeln: + +- jede ausstehende Unterbrechung genau einmal beantworten; +- doppelte und unbekannte IDs werden abgelehnt; +- `resolved` erfordert einen schema-konformen Payload; +- `cancelled` beendet die Unterbrechung und entspricht bei Berechtigungen `deny`; +- dauerhafter Pending-Status wird erst entfernt, nachdem A2A die Antwort akzeptiert hat; +- Schemafehler erzeugen `RUN_ERROR`, die Unterbrechung bleibt erneut beantwortbar; +- eine wiederholte, bereits akzeptierte Antwort führt das Werkzeug nicht erneut aus. + +Vor der Wiederaufnahme kann der Adapter A2A zur Wiederherstellung der iac-code-Sitzung auffordern, Task- und Kontextidentität prüfen und fehlende Pipeline-Ereignisse nachholen. + +## Dialogrunden und Identitäten + +```text +threadId (stabiles Gespräch) + ├─ runId-1 (Benutzerrunde) + ├─ runId-2 (Wiederaufnahme) + ├─ runId-3 (weitere Wiederaufnahme) + └─ runId-4 (nächste normale Nachricht) +``` + +Jede HTTP/SSE-Anfrage verwendet eine eindeutige `runId`. Eine Wiederaufnahme ist ein neuer Lauf. Nach einer normalen Runde erzeugt die nächste Nachricht eine neue Ausführung und verwendet die iac-code-Sitzung des Threads weiter. Idempotenz gilt im Bereich `(threadId, runId)`. + +## Abbrucherweiterung + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +Mögliche Ergebnisse sind `cancelled`, `already_terminal` oder HTTP `404` mit `EXECUTION_NOT_FOUND`. Der Abbruch entfernt ausstehende Unterbrechungen und ändert keine standardisierten AG-UI-Ereignisformate. + +## Persistenz und Wiederherstellung + +Standardverzeichnis: + +```text +/agui/threads/.json +``` + +Jede Datei enthält Thread-/Kontext-/Arbeitsbereichsbindung, Sitzungs-, Task- und Ausführungsidentität, Pipeline-Wiederaufnahmepositionen, ausstehende Unterbrechungen sowie Idempotenzdaten. Der Adapter lädt einen angefragten Thread verzögert und ersetzt atomar nur dessen kleine Datei. + +LLM-Schlüssel, AccessKey-Secrets und STS-Tokens werden nie gespeichert. Das Verzeichnis enthält Adapterzuordnungen, keine Gespräche oder Ausführungsartefakte. A2A verwaltet seine eigene Sitzungs- und Taskpersistenz; siehe [A2A-Dokumentation](../a2a/overview.md). + +Eine abgelaufene Unterbrechung wird beim nächsten Zugriff abgelehnt, ihr Pending-Status gelöscht und der passende A2A-Task nach Möglichkeit abgebrochen. + +## Verbindungsabbrüche + +- Ein Lauf, der sicher mit einer Unterbrechung endete, hängt nicht mehr von seiner SSE-Verbindung ab. +- Eine Wiederaufnahme erzeugt eine neue SSE-Verbindung. +- Bei Trennung eines gewöhnlichen aktiven Laufs bricht der Adapter den A2A-Task ab. +- Eine Trennung nach einer Unterbrechung löscht deren persistenten Wiederaufnahmestatus nicht. + +## Fehler + +Fehler vor Beginn von SSE verwenden einen HTTP-JSON-Umschlag. Fehler während der Ausführung verwenden standardisierte `RUN_ERROR`-Ereignisse. + +| Code | Bedeutung | +|------|-----------| +| `INVALID_INPUT` | Ungültiger Umschlag, Erweiterungswert, Nachrichteninhalt oder Arbeitsbereich | +| `DUPLICATE_RUN_ID` | Derselbe Anfrage-Digest verwendet eine bestehende Run-ID | +| `RUN_ID_CONFLICT` | Eine andere Anfrage verwendet eine bestehende Run-ID erneut | +| `THREAD_BUSY` | Der Thread besitzt bereits einen aktiven Lauf | +| `THREAD_BINDING_CONFLICT` | Arbeitsbereich oder Aufrufer widerspricht der Threadbindung | +| `RESUME_REQUIRED` | Der Thread wartet auf Unterbrechungsantworten | +| `INCOMPLETE_RESUME` | Fehlende Unterbrechungen oder doppelte IDs | +| `UNKNOWN_INTERRUPT` | Unbekannte Unterbrechung in der Wiederaufnahme | +| `RESUME_PAYLOAD_INVALID` | Fehlender Payload oder Schemaverstoß | +| `RESUME_ALREADY_APPLIED` | Antwort wurde bereits angewendet oder steht im Konflikt | +| `EXECUTION_EXPIRED` | Unterbrechung ist abgelaufen | +| `EXECUTION_LOST` | Adapter, A2A-Task oder iac-code-Sitzung konnte nicht wiederhergestellt werden | +| `STATE_PERSISTENCE_FAILED` | Wiederherstellungskritischer Status konnte nicht gespeichert werden | +| `A2A_UNAVAILABLE` | Lokaler A2A-Ausführungsdienst ist nicht verfügbar | +| `A2A_PROTOCOL_ERROR` | Task-/Kontext-/Sitzungsidentität widerspricht der Zuordnung | +| `A2A_EXECUTION_FAILED` | A2A-Task ist fehlgeschlagen | +| `CANCELLED` | Ausführung wurde abgebrochen | + +Wiederherstellungskritische Schreibfehler werden sicher behandelt. Der Adapter meldet keinen wiederherstellbaren Task, keine Sitzung und keine Unterbrechung, bevor die Zuordnung dauerhaft gespeichert ist, und bricht nötigenfalls den passenden A2A-Task ab. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current.json b/website/i18n/es/docusaurus-plugin-content-docs/current.json index 1ea7d0b6..1202557c 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/es/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "Protocolo A2A", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "Protocolo AG-UI", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "Automatizacion", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..f5081091 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,230 @@ +--- +sidebar_position: 2 +title: Primeros pasos +description: Instalación, arranque y uso del adaptador AG-UI de iac-code. +--- + +# Primeros pasos con AG-UI + +## Requisitos + +1. Python 3.10 o posterior. +2. Un proveedor LLM configurado para iac-code. Consulte [Autenticación](../configuration/authentication.md). +3. Para acceder a Alibaba Cloud, credenciales configuradas o credenciales temporales por solicitud. +4. Una ruta absoluta de workspace que iac-code pueda leer y escribir. + +Instale las dependencias AG-UI: + +```bash +pip install "iac-code[agui]" +``` + +Desde el repositorio fuente: + +```bash +uv sync --extra agui +``` + +## Opción 1: núcleo A2A local administrado + +Omita `--a2a-url`: + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +El adaptador elige un puerto loopback libre, inicia un proceso hijo `iac-code a2a` y lo detiene al salir. El hijo hereda la configuración y el entorno actuales. Es la opción más cómoda para desarrollo local. + +## Opción 2: núcleo A2A independiente + +Inicie A2A: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +Después inicie AG-UI: + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +A2A puede seguir atendiendo a sus propios clientes mientras AG-UI lo utiliza por loopback. `--thinking-exposure all` permite generar eventos `REASONING_*`; actívelo solo para clientes de confianza. Mantenga el valor predeterminado `tool-trace` si no desea exponer el razonamiento. + +Con Bearer token en A2A: + +```bash +export IACCODE_A2A_HTTP_TOKEN="a2a-local-secret" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Configure el mismo token upstream en AG-UI: + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="a2a-local-secret" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## Configuración YAML + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +```bash +iac-code agui --config agui-server.yml +``` + +Los argumentos CLI explícitos prevalecen sobre YAML. Inyecte los tokens mediante variables de entorno en vez de guardarlos en el archivo. + +| CLI / YAML | Predeterminado | Significado | +|------------|---------------|-------------| +| `--host` / `host` | `127.0.0.1` | Dirección HTTP | +| `--port` / `port` | `8000` | Puerto AG-UI; los ejemplos usan `41243` | +| `--a2a-url` / `a2a-url` | vacío | URL A2A local; vacío inicia un hijo | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Segundos durante los que puede reanudarse un Interrupt | +| `--state-dir` / `state-dir` | `/agui` | Estado por thread | +| `--idle-shutdown` / `idle-shutdown` | `0` | Cierre por inactividad; `0` lo desactiva | +| `--debug` / `debug` | `false` | Logs de depuración | +| `--log-stdout` / `log-stdout` | `false` | Copiar logs a stdout | + +| Variable | Uso | +|----------|-----| +| `IAC_CODE_AGUI_HOST` / `IAC_CODE_AGUI_PORT` | Dirección y puerto | +| `IAC_CODE_AGUI_A2A_URL` | URL upstream A2A local | +| `IAC_CODE_AGUI_A2A_TOKEN` | Bearer token de A2A | +| `IAC_CODE_AGUI_AUTH_TOKEN` | Bearer token del endpoint AG-UI | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Vigencia de Interrupt | +| `IAC_CODE_AGUI_STATE_DIR` | Directorio de estado | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | Raíces permitidas separadas con el separador de rutas del SO | +| `IAC_CODE_CONFIG_DIR` | Raíz de configuración de iac-code | + +## Comprobación de salud + +```bash +curl http://127.0.0.1:41243/health +``` + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "versión actual de iac-code" +} +``` + +## Cliente JavaScript oficial + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId: randomUUID(), + // Con IAC_CODE_AGUI_AUTH_TOKEN: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "es", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "Crea una plantilla de VPC con dos vSwitches.", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[tool] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[step] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +Pase `Authorization` mediante `HttpAgent.headers` si el endpoint usa token. En un navegador, use normalmente un backend del mismo origen o un proxy inverso; el adaptador no configura CORS. + +## Resolver Interrupt + +El cliente mantiene los Interrupt en `agent.pendingInterrupts`. Construya cada respuesta según su `responseSchema`: + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +Este payload solo sirve para permisos cuyo schema exige `decision`. Preguntas y selecciones tienen otros schemas. + +Resume conserva `threadId` y `rosInvocationId`, usa un `runId` nuevo, responde una vez a todos los Interrupt pendientes y proporciona un payload válido para `resolved`. Use `cancelled` si el usuario decide no continuar. + +## Iniciar un Pipeline + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +Procese `STEP_*`, `TOOL_CALL_*`, `ACTIVITY_SNAPSHOT` y `CUSTOM`. Los clientes genéricos que ignoren las extensiones siguen recibiendo todos los eventos estándar. + +## Workspace, credenciales y estado + +Cada solicitud proporciona un `cwd` absoluto bajo una raíz permitida por `IAC_CODE_AGUI_ALLOWED_CWDS` o `IACCODE_A2A_ALLOWED_CWDS`. El modelo, la clave LLM y las credenciales temporales de Alibaba Cloud pueden enviarse en `forwardedProps.iacCode`; el adaptador no las guarda en su estado. + +El estado predeterminado se distribuye por thread: + +```text +/agui/threads/.json +``` + +No se examinan todos los threads al iniciar. Los UUID conservan nombres legibles; los ID inseguros se codifican y los muy largos usan una clave de longitud fija. El JSON siempre conserva y valida el `threadId` original. Solo se guardan relaciones, Interrupt e idempotencia, nunca el contenido de la conversación ni credenciales. + +## Continúe leyendo + +- [Descripción general](./overview.md) +- [Referencia del protocolo](./protocol-reference.md) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..aaaa56a1 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,125 @@ +--- +sidebar_position: 1 +title: Protocolo AG-UI +description: Arquitectura, funciones y casos de uso de la integración AG-UI de iac-code. +--- + +# Protocolo AG-UI + +## Qué es AG-UI + +El [Agent-User Interaction Protocol (AG-UI)](https://docs.ag-ui.com/concepts/architecture) es un protocolo de eventos entre agentes y aplicaciones orientadas al usuario. El cliente inicia una ejecución mediante `RunAgentInput` y recibe por HTTP Server-Sent Events (SSE) eventos estructurados de texto, razonamiento, herramientas, pasos, estado e interrupciones. + +Resulta adecuado para consolas web, clientes de chat, extensiones de IDE y otras interfaces que deban mostrar la ejecución en tiempo real. En lugar de presentar solo el texto final, pueden representar por separado la respuesta del modelo, los argumentos y resultados de herramientas, los pasos del Pipeline y las operaciones pendientes de confirmación. + +## Arquitectura de iac-code + +iac-code utiliza un **núcleo de ejecución A2A y un adaptador de protocolo AG-UI**: + +```text +AG-UI client + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Agent loop / Pipeline / LLM / Alibaba Cloud API +``` + +`iac-code a2a` es el único núcleo de ejecución y gestiona: + +- conversaciones normales y ejecución de Pipelines; +- sesiones de iac-code, contextos y tareas A2A; +- permisos de herramientas, preguntas, selección de opciones y recuperación; +- ciclo de vida y cancelación; +- llamadas al LLM y a las API de Alibaba Cloud. + +`iac-code agui` no crea otro Agent runtime ni ejecuta Pipelines directamente. Se limita a: + +- convertir `RunAgentInput` en solicitudes A2A; +- proyectar eventos A2A como eventos AG-UI estándar; +- relacionar `threadId/runId` con `contextId/taskId`; +- convertir `resume[]` en recuperación de entrada A2A; +- conservar las relaciones de protocolo y los Interrupt pendientes; +- reenviar cancelaciones a A2A. + +Por tanto, AG-UI y A2A no implementan reglas de ejecución distintas. El mismo runtime A2A aplica el modelo, las credenciales de nube, los permisos y el comportamiento del Pipeline. + +## Protocolo estándar y extensiones de iac-code + +El flujo externo utiliza eventos AG-UI estándar: `RUN_*`, `TEXT_MESSAGE_*`, `REASONING_*`, `TOOL_CALL_*`, `STEP_*` y `ACTIVITY_SNAPSHOT`. + +Solo la información útil del Pipeline que no tenga un equivalente estándar se publica como `CUSTOM` con espacio de nombres. Un cliente AG-UI genérico puede ignorar esos eventos sin afectar al texto, las herramientas, los Interrupt ni el ciclo del run. + +La solicitud sigue siendo un `RunAgentInput` estándar. iac-code usa `forwardedProps` para los datos de ejecución obligatorios: + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "request-identity", + "cwd": "/absolute/workspace/path", + "runMode": "normal" + } + } +} +``` + +Un cliente genérico puede consumir todos los eventos estándar. Si llama directamente a `iac-code agui`, debe proporcionar campos como `cwd` en `forwardedProps.iacCode`. + +## Interacciones compatibles + +### Conversaciones normales con varios turnos + +Se conserva el mismo `threadId` durante toda la conversación y se crea un `runId` nuevo para cada turno. El adaptador vincula el thread con una sesión de iac-code. El turno siguiente abre otra solicitud HTTP/SSE; nunca continúa en una respuesta SSE ya finalizada. + +### Pipeline + +Con `forwardedProps.iacCode.runMode: "pipeline"`, el núcleo A2A sigue ejecutando el Pipeline. Los pasos superiores se convierten en `STEP_*`; el texto, el razonamiento y las herramientas usan sus eventos estándar. Los candidatos, el progreso del stack y la limpieza sin equivalente estándar se publican como `iac-code.pipeline.v1`. + +Los sub-pipelines paralelos usan identificadores de mensaje y paso distintos, por lo que no se mezcla el texto de varios agent loops. + +### Interrupt y Resume + +Cuando una autorización, pregunta o selección exige intervención, el run actual termina con: + +```json +{ + "type": "RUN_FINISHED", + "outcome": {"type": "interrupt", "interrupts": []} +} +``` + +El Interrupt se guarda antes de enviarse al cliente. Este reúne las respuestas e inicia una solicitud nueva con el mismo `threadId`, un `runId` nuevo y `resume[]`. El SSE de Resume pertenece a esa nueva solicitud, no al flujo anterior. + +### Estado del adaptador + +El adaptador guarda por thread las relaciones de protocolo, los datos de idempotencia y los Interrupt pendientes. Este directorio no contiene el texto de la conversación, claves LLM ni credenciales de nube, y no sirve para exportar conversaciones. + +## Cuándo usar AG-UI + +| Necesidad | Modo recomendado | +|-----------|------------------| +| Interfaz de chat con texto, razonamiento, herramientas y pasos en directo | **AG-UI** | +| Permisos, preguntas y selección desde una UI | **AG-UI** | +| Otro agente u orquestador llama directamente a iac-code | **A2A** | +| Integración de IDE/editor con sesiones ACP | **ACP** | +| Uso manual local | **REPL interactivo o Web/Desktop** | + +AG-UI y A2A pueden ejecutarse simultáneamente. Exponen endpoints distintos, pero comparten la misma implementación de ejecución. + +## Límites actuales + +- Transporte HTTP POST + SSE. +- El upstream A2A debe ser una dirección loopback. +- `cwd` es obligatorio en cada solicitud y debe estar bajo una raíz permitida. +- No se aceptan `tools` definidos por el cliente; iac-code administra las herramientas. +- Los mensajes admiten texto e imágenes base64 en línea, no URL remotas. +- Si el cliente desconecta un run activo antes de un Interrupt, se cancela la tarea A2A. +- Un comentario heartbeat se envía cada 15 segundos y los clientes conformes lo ignoran. + +## Siguientes pasos + +- [Primeros pasos](./getting-started.md) +- [Referencia del protocolo](./protocol-reference.md) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..79510ccd --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,161 @@ +--- +sidebar_position: 3 +title: Referencia del protocolo +description: Solicitudes, eventos, Interrupt, Resume, cancelación y persistencia de AG-UI en iac-code. +--- + +# Referencia del protocolo AG-UI + +Esta página describe la interfaz HTTP/SSE de `iac-code agui` y las extensiones de iac-code dentro del envelope AG-UI estándar. Consulte antes la [descripción general](./overview.md) y los [primeros pasos](./getting-started.md). + +## Endpoints HTTP + +| Método y ruta | Uso | +|---------------|-----| +| `GET /health` | Salud y versiones | +| `POST /` | Enviar `RunAgentInput` y recibir SSE | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | Extensión de cancelación | + +Use JSON y solicite SSE: + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +Con `IAC_CODE_AGUI_AUTH_TOKEN`, añada `Authorization: Bearer `. `Accept-Language` actúa como idioma alternativo; `forwardedProps.iacCode.preferredLanguage` tiene prioridad y se reenvía a A2A. + +## RunAgentInput + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [{"id": "message-1", "role": "user", "content": "Crea una plantilla de VPC"}], + "tools": [], + "context": [], + "forwardedProps": {"iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + }} +} +``` + +| Campo estándar | Requisito y comportamiento | +|----------------|---------------------------| +| `threadId` | Obligatorio y estable durante la conversación | +| `runId` | Obligatorio y único por solicitud HTTP/SSE | +| `parentRunId` | Opcional; se copia a `RUN_STARTED` | +| `state` | Obligatorio; no es el estado del runtime de iac-code | +| `messages` | Obligatorio; un run nuevo usa el último mensaje de usuario | +| `tools` | Obligatorio y vacío; no admite herramientas del cliente | +| `context` | Obligatorio; actualmente no se convierte en contexto del prompt | +| `forwardedProps` | Obligatorio con la extensión `iacCode` | +| `resume` | Respuestas a todos los Interrupt pendientes | + +Los mensajes admiten texto e imágenes base64 en línea. No se admiten URL remotas, audio, vídeo, documentos ni binarios genéricos. Límites: 8 MiB por imagen, 10 MiB en total y 12 MiB por solicitud. + +## `forwardedProps.iacCode` + +El schema es estricto y rechaza campos desconocidos. + +| Campo | Tipo | Obligatorio | Uso | +|-------|------|-------------|-----| +| `schemaVersion` | `1` | Sí | Versión de extensión | +| `rosInvocationId` | string | Sí | Identidad de la ejecución, máximo 256 caracteres | +| `cwd` | string | Sí | Workspace absoluto | +| `model` / `llmApiKey` | string | No | Modelo y clave LLM por solicitud | +| `thinking.enabled/effort/budget` | boolean/string/entero positivo | No | Opciones de thinking | +| `userId` / `channel` | string | No | Identidad y canal del llamante | +| `preferredLanguage` | string | No | Idioma visible, por ejemplo `es` | +| `candidatePresentation` | `standard` / `rich` | No | Presentación de candidatos | +| `runMode` | `normal` / `pipeline` | No | Modo de ejecución | +| `pipelineName` | string | No | Nombre del Pipeline | +| `cleanupOnly` | boolean | No | Ejecutar solo limpieza | +| `alibabaCloud.accessKeyId` | string | No | AccessKey ID temporal | +| `alibabaCloud.accessKeySecret` | string | No | AccessKey Secret temporal | +| `alibabaCloud.securityToken` | string | No | Token STS temporal | +| `alibabaCloud.regionId` | string | No | Región predeterminada | + +El run inicial y sus Resume conservan el mismo `rosInvocationId`. Un turno normal posterior puede usar otro. El mismo `threadId` queda vinculado al primer `cwd` y `userId`. + +## SSE y eventos estándar + +Tras 15 segundos sin eventos, el servidor envía `: heartbeat`. Es un comentario SSE, no un evento `CUSTOM`. + +| Señal | Evento AG-UI | +|-------|-------------| +| Solicitud aceptada | `RUN_STARTED` | +| Texto | `TEXT_MESSAGE_*` | +| Razonamiento | `REASONING_*` | +| Herramienta y argumentos | `TOOL_CALL_START/ARGS/END` | +| Resultado | `TOOL_CALL_RESULT` | +| Paso de Pipeline | `STEP_STARTED/STEP_FINISHED` | +| Snapshot de recuperación | `ACTIVITY_SNAPSHOT` | +| Éxito o espera de entrada | `RUN_FINISHED` con outcome `success` o `interrupt` | +| Error | `RUN_ERROR` | + +`RUN_FINISHED` finaliza un run, no necesariamente el Pipeline. Los Interrupt producen runs nuevos. Antes de terminar por Interrupt se cierran los spans abiertos y el nuevo run reabre los pasos duraderos activos; no indica ejecución en orden inverso. + +## Eventos personalizados + +- `iac-code.session.v1`: relaciones de thread, execution, context, task y session; `executionId` permite cancelar. +- `iac-code.artifact.v1`: proyección de artifacts A2A. +- `iac-code.tool-progress.v1`: progreso intermedio sin equivalente estándar. +- `iac-code.pipeline.v1`: datos útiles del Pipeline sin equivalente estándar. + +Tipos de Pipeline admitidos: + +- `pipeline_started`, `pipeline_resumed`, `pipeline_completed`, `pipeline_error`, `pipeline_warning`, `backup_blocked`; +- `candidate_started`, `candidate_completed`, `candidate_failed`, `candidate_interrupted`, `candidate_restart_requested`, `candidate_selected`, `candidate_detail_shown`, `candidate_step_failed`; +- `sub_pipeline_started`, `sub_pipeline_completed`, `sub_step_failed`, `step_failed`; +- `stack_progress`, `stack_instances_progress`, `stack_current_changed`, `cleanup_started`, `cleanup_progress`, `cleanup_completed`, `cleanup_failed`; +- `rollback_triggered`, `rollback_completed`; +- `context_compaction_started`, `context_compacted`, `context_compaction_failed`, `fields_marked_stale`; +- `diagram_shown`, `mcp_status`, `tool_progress`. + +`text_delta`, `thinking_delta`, `tool_started/tool_result`, `usage` y el ciclo de pasos ya tienen eventos estándar y no se duplican como `CUSTOM`. Deduzca repeticiones por `eventId` o sequence. + +## Interrupt y Resume + +Cada Interrupt contiene `id`, `reason`, `message`, `responseSchema`, `expiresAt`, un `toolCallId` opcional y metadata descriptiva. La autorización suele aceptar `{"decision":"allow_once"}` o `{"decision":"deny"}`. La UI debe respetar el schema en vez de deducir la respuesta solo a partir de `reason`. + +Resume es otra solicitud con el mismo `threadId`, un `runId` nuevo y el mismo `rosInvocationId`: + +```json +{"resume": [{ + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} +}]} +``` + +Debe responder exactamente una vez a todos los Interrupt pendientes. `resolved` exige un payload válido; `cancelled` cancela y equivale a `deny` para permisos. Un error de schema genera `RUN_ERROR` y mantiene el Interrupt disponible. Repetir una respuesta aceptada no vuelve a ejecutar la herramienta. + +## Identidades y cancelación + +Cada solicitud usa un `runId` único dentro del `threadId`; un Resume también es un run nuevo. La idempotencia se limita a `(threadId, runId)`. + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +Responde `cancelled`, `already_terminal` o `EXECUTION_NOT_FOUND`. La cancelación borra los Interrupt pendientes. + +## Persistencia, desconexión y errores + +El estado se guarda en `/agui/threads/.json`. Contiene relaciones, identidades, posiciones del Pipeline, Interrupt e idempotencia; carga solo el thread solicitado y sustituye atómicamente un archivo pequeño. No almacena claves LLM, secretos de AccessKey, STS token, texto de conversación ni artifacts. A2A gestiona su propia persistencia; consulte su [documentación](../a2a/overview.md). + +Un Interrupt expirado se rechaza y se limpia. Un run terminado con Interrupt ya no depende de su SSE; desconectar un run normal activo cancela la tarea A2A. + +Antes de SSE, los errores usan JSON HTTP; durante la ejecución usan `RUN_ERROR`. Los códigos principales son `INVALID_INPUT`, `DUPLICATE_RUN_ID`, `RUN_ID_CONFLICT`, `THREAD_BUSY`, `THREAD_BINDING_CONFLICT`, `RESUME_REQUIRED`, `INCOMPLETE_RESUME`, `UNKNOWN_INTERRUPT`, `RESUME_PAYLOAD_INVALID`, `RESUME_ALREADY_APPLIED`, `EXECUTION_EXPIRED`, `EXECUTION_LOST`, `STATE_PERSISTENCE_FAILED`, `A2A_UNAVAILABLE`, `A2A_PROTOCOL_ERROR`, `A2A_EXECUTION_FAILED` y `CANCELLED`. + +Las escrituras necesarias para la recuperación fallan de forma cerrada: el adaptador no anuncia un estado recuperable antes de guardarlo y cancela la tarea A2A cuando sea necesario. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current.json b/website/i18n/fr/docusaurus-plugin-content-docs/current.json index 5958d070..164dad33 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "Protocole A2A", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "Protocole AG-UI", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "Automatisation", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..5f6cf658 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,259 @@ +--- +sidebar_position: 2 +title: Bien démarrer +description: Installer, démarrer et appeler l’adaptateur AG-UI d’iac-code. +--- + +# Bien démarrer avec AG-UI + +## Prérequis + +1. Python 3.10 ou une version ultérieure est installé. +2. Un fournisseur LLM est configuré pour iac-code. Consultez [Authentification](../configuration/authentication.md). +3. Si la tâche accède à Alibaba Cloud, configurez des identifiants cloud ou fournissez des identifiants temporaires dans la requête. +4. Vous disposez d’un chemin absolu vers un espace de travail accessible en lecture et écriture par iac-code. + +Installez les dépendances AG-UI : + +```bash +pip install "iac-code[agui]" +``` + +Pour développer depuis le dépôt source : + +```bash +uv sync --extra agui +``` + +## Option 1 : démarrer un noyau A2A local géré + +Pour la configuration locale la plus simple, omettez `--a2a-url` : + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +L’adaptateur choisit un port de bouclage disponible, démarre un processus enfant `iac-code a2a` et l’arrête à sa fermeture. L’enfant hérite de la configuration et de l’environnement d’exécution actuels. + +Ce mode convient au développement local et à une gestion unifiée du cycle de vie. En production, utilisez l’option suivante si le superviseur doit gérer les deux services séparément. + +## Option 2 : se connecter à un noyau A2A indépendant + +Démarrez d’abord le serveur A2A : + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +Puis démarrez l’adaptateur AG-UI : + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +Les services conservent des responsabilités et des ports distincts. A2A peut continuer à servir ses propres clients, tandis que l’adaptateur l’appelle uniquement par l’interface de bouclage. + +`--thinking-exposure all` permet de convertir le raisonnement brut en événements standard `REASONING_*`. Ne l’activez que pour des clients de confiance. Conservez la valeur A2A par défaut, `tool-trace`, si le contenu du raisonnement ne doit pas être exposé. + +Si le serveur A2A utilise un jeton bearer : + +```bash +export IACCODE_A2A_HTTP_TOKEN="secret-a2a-local" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Fournissez le même jeton amont à l’adaptateur : + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="secret-a2a-local" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## Configuration YAML + +Les paramètres statiques peuvent être enregistrés dans un fichier YAML : + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +Démarrez l’adaptateur avec : + +```bash +iac-code agui --config agui-server.yml +``` + +Les arguments CLI explicites remplacent le YAML. Injectez les valeurs sensibles, comme les jetons, par variables d’environnement plutôt que dans le fichier. + +| CLI / YAML | Valeur par défaut | Signification | +|------------|-------------------|---------------| +| `--host` / `host` | `127.0.0.1` | Adresse d’écoute HTTP AG-UI | +| `--port` / `port` | `8000` | Port HTTP AG-UI ; les exemples de déploiement utilisent `41243` | +| `--a2a-url` / `a2a-url` | vide | URL A2A locale ; vide démarre un enfant géré | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Durée en secondes pendant laquelle une interruption peut être reprise | +| `--state-dir` / `state-dir` | `/agui` | Répertoire d’état des threads AG-UI | +| `--idle-shutdown` / `idle-shutdown` | `0` | Arrêt après inactivité ; `0` le désactive | +| `--debug` / `debug` | `false` | Journalisation de débogage | +| `--log-stdout` / `log-stdout` | `false` | Dupliquer les journaux sur stdout | + +Variables d’environnement associées : + +| Variable | Rôle | +|----------|------| +| `IAC_CODE_AGUI_HOST` | Adresse d’écoute AG-UI | +| `IAC_CODE_AGUI_PORT` | Port AG-UI | +| `IAC_CODE_AGUI_A2A_URL` | URL locale du service A2A amont | +| `IAC_CODE_AGUI_A2A_TOKEN` | Jeton bearer du service A2A amont | +| `IAC_CODE_AGUI_AUTH_TOKEN` | Jeton bearer protégeant le point d’accès AG-UI | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Durée de vie des interruptions | +| `IAC_CODE_AGUI_STATE_DIR` | Répertoire d’état des threads AG-UI | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | Racines d’espace de travail autorisées, séparées par le séparateur de chemins du système | +| `IAC_CODE_CONFIG_DIR` | Racine de configuration d’iac-code et parent par défaut de l’état AG-UI | + +## Vérification de l’état + +```bash +curl http://127.0.0.1:41243/health +``` + +Exemple de réponse : + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "version actuelle d’iac-code" +} +``` + +## Utiliser le client JavaScript officiel + +Installez la version vérifiée : + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +Cet exemple se connecte directement à `iac-code agui` avec le `HttpAgent` standard et fournit les propriétés d’exécution dans `forwardedProps` : + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const threadId = randomUUID(); +const rosInvocationId = randomUUID(); +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId, + // Si IAC_CODE_AGUI_AUTH_TOKEN est configuré : + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId, + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "fr", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "Crée un modèle VPC avec deux vSwitches.", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[outil] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[étape] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +Avec un jeton bearer, transmettez `Authorization` dans `HttpAgent.headers`. Une application web passe normalement par un backend de même origine ou un proxy inverse ; l’adaptateur n’ajoute pas de politique CORS. + +## Traiter les interruptions + +Le client officiel conserve `RUN_FINISHED.outcome.interrupts` dans `agent.pendingInterrupts`. Construisez chaque réponse à partir de son `responseSchema`, puis envoyez-la dans une nouvelle exécution : + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +Ce payload ne s’applique qu’aux autorisations dont le schéma exige `decision`. Les questions et choix d’options ont leurs propres schémas. + +Une reprise doit utiliser le `threadId` d’origine, un nouveau `runId`, conserver le `rosInvocationId` de l’exécution interrompue, répondre en une seule requête à toutes les interruptions en attente et respecter chaque `responseSchema`. Utilisez `status: "cancelled"` lorsque l’utilisateur abandonne. + +## Démarrer un Pipeline + +Définissez `runMode` sur `pipeline` et choisissez éventuellement un Pipeline : + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +Les clients devraient traiter `STEP_*`, `TOOL_CALL_*`, `ACTIVITY_SNAPSHOT` et `CUSTOM`. Un client générique qui ignore les événements personnalisés d’iac-code continue de traiter normalement tous les événements standard. + +## Espace de travail et identifiants temporaires + +`cwd` n’est pas fixé au démarrage du serveur. Chaque requête doit fournir un chemin absolu sous une racine autorisée par `IAC_CODE_AGUI_ALLOWED_CWDS` ou `IACCODE_A2A_ALLOWED_CWDS`. + +Le demandeur peut fournir, par requête, un modèle, une clé LLM et des identifiants Alibaba Cloud temporaires via `forwardedProps.iacCode`. L’adaptateur ne les écrit pas dans son état ; il les transmet au noyau A2A selon les règles habituelles de surcharge de requête. + +## Répertoire d’état + +Disposition par défaut : + +```text +/agui/ + threads/ + .json +``` + +Chaque thread est écrit indépendamment et le démarrage ne parcourt pas l’historique. Les UUID normaux restent lisibles ; les identifiants dangereux sont encodés et les identifiants très longs utilisent une clé de fichier de longueur fixe. Le document JSON conserve et vérifie toujours le `threadId` original. + +Ce répertoire contient uniquement les associations, interruptions et données d’idempotence de l’adaptateur. Il ne contient ni conversation ni identifiants de requête. Ne modifiez pas ces fichiers JSON manuellement. + +## Étapes suivantes + +- [Présentation d’AG-UI](./overview.md) +- [Référence du protocole](./protocol-reference.md) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..47b8ffa8 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,135 @@ +--- +sidebar_position: 1 +title: Protocole AG-UI +description: Architecture, fonctionnalités et cas d’usage de l’intégration AG-UI d’iac-code. +--- + +# Protocole AG-UI + +## Qu’est-ce qu’AG-UI ? + +Le [protocole d’interaction agent-utilisateur (AG-UI)](https://docs.ag-ui.com/concepts/architecture) est un protocole de flux d’événements reliant des agents à des applications destinées aux utilisateurs. Un client démarre une exécution avec `RunAgentInput`, puis reçoit par HTTP Server-Sent Events (SSE) des événements structurés pour le texte, le raisonnement, les appels d’outils, les étapes, l’état et les interruptions. + +AG-UI convient aux consoles web, clients de chat, extensions d’IDE et autres applications qui doivent afficher l’exécution d’un agent en temps réel. Au lieu de recevoir uniquement le texte final, le client peut présenter séparément la sortie du modèle, les arguments et résultats des outils, les étapes d’un Pipeline et les opérations en attente de confirmation. + +## Architecture d’iac-code + +iac-code utilise un **noyau d’exécution A2A associé à un adaptateur de protocole AG-UI** : + +```text +Client AG-UI + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Boucle de l’agent / Pipeline / LLM / API Alibaba Cloud +``` + +`iac-code a2a` est l’unique noyau d’exécution. Il gère : + +- les conversations normales et l’exécution des Pipelines ; +- les sessions iac-code ainsi que les contextes et tâches A2A ; +- les autorisations d’outils, questions, choix d’options et reprises ; +- le cycle de vie et l’annulation des exécutions ; +- les appels au LLM et aux API Alibaba Cloud. + +`iac-code agui` ne crée pas un second runtime Agent et n’exécute pas directement les Pipelines. Il se limite à : + +- convertir `RunAgentInput` en requêtes A2A ; +- projeter les événements A2A en événements AG-UI standard ; +- associer `threadId/runId` à `contextId/taskId` ; +- convertir `resume[]` en reprise d’entrée A2A ; +- persister les associations de protocole et les interruptions en attente ; +- transmettre les annulations à A2A. + +AG-UI et A2A partagent donc les mêmes règles d’exécution. Le choix du modèle, les identifiants cloud, les autorisations et le comportement du Pipeline restent gérés par le runtime A2A. + +## Protocole standard et extensions iac-code + +Le flux externe utilise les événements AG-UI standard : + +- `RUN_STARTED`, `RUN_FINISHED` et `RUN_ERROR` ; +- `TEXT_MESSAGE_*` ; +- `REASONING_*` ; +- `TOOL_CALL_*` ; +- `STEP_STARTED` et `STEP_FINISHED` ; +- `ACTIVITY_SNAPSHOT`. + +Seules les informations de Pipeline utiles qui n’ont pas d’équivalent standard sont envoyées dans des événements `CUSTOM` avec espace de noms. Un client AG-UI générique peut les ignorer sans perturber le texte, les outils, les interruptions ou le cycle de vie de l’exécution. + +Les requêtes conservent l’enveloppe standard `RunAgentInput`. Le champ standard `forwardedProps` transporte l’espace de travail, le mode d’exécution et les autres données nécessaires : + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "identite-requete", + "cwd": "/chemin/absolu/espace-travail", + "runMode": "normal" + } + } +} +``` + +Un client générique peut donc consommer directement les événements standard d’iac-code. Pour appeler `iac-code agui` directement, il doit néanmoins fournir les données d’exécution obligatoires, notamment `cwd`, sous `forwardedProps.iacCode`. + +## Interactions prises en charge + +### Conversations normales en plusieurs tours + +Conservez le même `threadId` pour toute la conversation et utilisez un nouveau `runId` pour chaque tour utilisateur. L’adaptateur lie le thread à une session iac-code. Une fois un tour terminé, le message suivant ouvre une nouvelle requête HTTP/SSE ; il ne prolonge jamais l’ancienne réponse SSE déjà terminée. + +### Pipeline + +Définissez `forwardedProps.iacCode.runMode` sur `pipeline`. Le noyau A2A exécute toujours le Pipeline. Les étapes principales deviennent des événements standard `STEP_*`, tandis que le texte, le raisonnement et les outils utilisent leurs événements standard respectifs. Les informations sur les candidats, la progression des stacks et le nettoyage sans équivalent standard sont envoyées via `iac-code.pipeline.v1`. + +Les sous-Pipelines parallèles utilisent des identités de messages et d’étapes distinctes, afin que le texte de plusieurs boucles d’agent ne soit pas fusionné. + +### Interruption et reprise + +Lorsqu’une autorisation, une question ou un choix exige une réponse, l’exécution courante se termine ainsi : + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [] + } +} +``` + +L’interruption est persistée avant d’être visible par le client. Après avoir recueilli les réponses, celui-ci démarre une nouvelle requête avec le même `threadId`, un nouveau `runId` et `resume[]`. Le flux de reprise appartient à cette nouvelle requête et ne se reconnecte pas à l’ancien flux. + +### État de l’adaptateur + +L’adaptateur conserve les associations de protocole, les données d’idempotence et les interruptions en attente dans un fichier par thread. Ce répertoire ne contient ni texte de conversation, ni clés LLM, ni identifiants cloud, et ne sert pas à exporter les conversations. + +## Quel protocole choisir ? + +| Besoin | Mode recommandé | +|--------|-----------------| +| Créer une interface de chat avec texte, raisonnement, outils et étapes en direct | **AG-UI** | +| Gérer les autorisations, questions et choix dans une interface | **AG-UI** | +| Permettre à un autre agent ou orchestrateur d’appeler directement iac-code | **A2A** | +| Intégrer un IDE/éditeur avec sessions ACP et terminal | **ACP** | +| Utiliser iac-code manuellement | **REPL interactif ou Web/Desktop** | + +AG-UI et A2A peuvent fonctionner simultanément. Ils exposent des points d’accès HTTP distincts tout en partageant la même implémentation d’exécution. + +## Limites actuelles + +- Le transport AG-UI repose sur HTTP POST et SSE. +- Le service A2A en amont doit utiliser une adresse de bouclage ; l’adaptateur refuse les URL A2A distantes arbitraires. +- `cwd` est obligatoire pour chaque requête et doit se trouver sous une racine d’espace de travail autorisée. +- Les `tools` définis par le client ne sont pas encore acceptés ; iac-code contrôle l’ensemble des outils. +- Les messages utilisateur acceptent le texte et les images base64 intégrées, mais pas les URL de médias distants. +- Si le client se déconnecte d’une exécution SSE active avant une interruption, l’adaptateur annule la tâche A2A correspondante. +- Le flux SSE envoie un commentaire heartbeat toutes les 15 secondes. Les clients conformes l’ignorent. + +## Étapes suivantes + +- [Bien démarrer](./getting-started.md) — installer, démarrer et connecter un premier client. +- [Référence du protocole](./protocol-reference.md) — champs de requête, événements, interruptions, reprise, persistance et erreurs. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..d354862d --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,287 @@ +--- +sidebar_position: 3 +title: Référence du protocole +description: Référence des requêtes, événements, interruptions, reprises, annulations et de la persistance AG-UI d’iac-code. +--- + +# Référence du protocole AG-UI + +Cette page décrit l’interface HTTP/SSE exposée par `iac-code agui` et les champs d’extension iac-code transportés dans les enveloppes AG-UI standard. Consultez d’abord la [présentation](./overview.md) et le [guide de démarrage](./getting-started.md). + +## Points d’accès HTTP + +| Méthode et chemin | Rôle | +|-------------------|------| +| `GET /health` | État du service et versions du protocole | +| `POST /` | Envoyer `RunAgentInput` et recevoir un flux SSE | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | Extension d’annulation avec espace de noms | + +Le corps de `POST /` doit être en JSON et le client doit demander SSE : + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +Si `IAC_CODE_AGUI_AUTH_TOKEN` est configuré : + +```http +Authorization: Bearer +``` + +L’en-tête standard `Accept-Language` sert de langue de repli pour les erreurs. `forwardedProps.iacCode.preferredLanguage` est prioritaire et est aussi transmis au runtime A2A. + +## RunAgentInput + +Exemple minimal d’exécution normale : + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [ + {"id": "message-1", "role": "user", "content": "Créer un modèle VPC"} + ], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +### Champs standard + +| Champ | Exigence | Comportement d’iac-code | +|-------|----------|-------------------------| +| `threadId` | Chaîne non vide obligatoire | Identité stable de conversation, associée à un contexte A2A et une session iac-code | +| `runId` | Chaîne non vide obligatoire | Une exécution HTTP/SSE ; ne peut pas être réutilisée dans le thread | +| `parentRunId` | Facultatif | Recopié dans `RUN_STARTED` | +| `state` | Obligatoire | Conservé dans l’enveloppe standard, mais non utilisé comme état runtime d’iac-code | +| `messages` | Obligatoire | Une nouvelle exécution utilise le dernier message utilisateur ; une reprise n’a pas besoin d’en ajouter | +| `tools` | Obligatoire et vide | Les outils définis par le client ne sont pas pris en charge | +| `context` | Obligatoire | Conservé dans l’enveloppe, mais pas encore converti en contexte de prompt | +| `forwardedProps` | Obligatoire | Doit contenir l’extension `iacCode` | +| `resume` | Pour une reprise | Une réponse pour chaque interruption en attente | + +Les messages utilisateur acceptent les chaînes, les parties `text` et les parties `image` contenant une source `data` base64 intégrée. Les URL d’image distantes, l’audio, la vidéo, les documents et les binaires génériques ne sont pas pris en charge. Une image décodée est limitée à 8 Mio, l’ensemble des images à 10 Mio et la requête HTTP complète à 12 Mio. + +## `forwardedProps.iacCode` + +Le schéma est strict : les champs inconnus sont refusés. + +| Champ | Type | Obligatoire | Signification | +|-------|------|-------------|---------------| +| `schemaVersion` | `1` | Oui | Version de l’extension iac-code | +| `rosInvocationId` | chaîne | Oui | Identité du demandeur pour l’exécution courante, 256 caractères maximum | +| `cwd` | chaîne | Oui | Chemin absolu de l’espace de travail | +| `model` | chaîne | Non | Modèle choisi pour cette requête | +| `llmApiKey` | chaîne | Non | Clé du fournisseur LLM pour cette requête | +| `thinking.enabled` | booléen | Non | Demander la sortie du raisonnement | +| `thinking.effort` | chaîne | Non | Effort de raisonnement propre au fournisseur | +| `thinking.budget` | entier positif | Non | Budget de raisonnement propre au fournisseur | +| `userId` | chaîne | Non | Identité de télémétrie et de liaison du demandeur | +| `channel` | chaîne | Non | Métadonnées du canal appelant | +| `preferredLanguage` | chaîne | Non | Langue d’affichage locale à la requête, par exemple `fr` | +| `candidatePresentation` | `standard` ou `rich` | Non | Présentation des candidats du Pipeline | +| `runMode` | `normal` ou `pipeline` | Non | Mode d’exécution, sinon choisi par A2A | +| `pipelineName` | chaîne | Non | Nom du Pipeline, par exemple `selling` | +| `cleanupOnly` | booléen | Non | Demander uniquement le nettoyage du Pipeline | +| `alibabaCloud.accessKeyId` | chaîne | Non | AccessKey ID locale à la requête | +| `alibabaCloud.accessKeySecret` | chaîne | Non | Secret AccessKey local à la requête | +| `alibabaCloud.securityToken` | chaîne | Non | Jeton STS local à la requête | +| `alibabaCloud.regionId` | chaîne | Non | Région par défaut locale à la requête | + +L’exécution initiale et ses reprises doivent conserver le même `rosInvocationId`. Un tour normal ultérieur peut utiliser une nouvelle valeur. L’annulation doit employer celle de l’exécution courante. + +Le `threadId` est lié aux `cwd` et `userId` de la première requête ; les requêtes suivantes ne peuvent pas déplacer le thread vers un autre espace de travail ou un autre demandeur. + +## SSE et heartbeat + +Chaque événement AG-UI est envoyé dans un enregistrement SSE `data:`. Après 15 secondes sans événement, le serveur envoie : + +```text +: heartbeat +``` + +Il s’agit d’un commentaire SSE, pas d’un événement AG-UI `CUSTOM`. Les clients conformes l’ignorent tout en maintenant la connexion HTTP active. + +## Correspondance des événements standard + +| Signal A2A/iac-code | Sortie AG-UI | +|---------------------|--------------| +| Requête acceptée | `RUN_STARTED` | +| Texte de l’agent | `TEXT_MESSAGE_START/CONTENT/END` | +| Raisonnement brut | `REASONING_START`, `REASONING_MESSAGE_*`, `REASONING_END` | +| Démarrage et arguments d’un outil | `TOOL_CALL_START/ARGS/END` | +| Résultat d’un outil | `TOOL_CALL_RESULT` | +| Cycle de vie d’une étape de Pipeline | `STEP_STARTED/STEP_FINISHED` | +| Instantané de reprise du Pipeline | `ACTIVITY_SNAPSHOT` | +| Fin normale | `RUN_FINISHED` avec `outcome.type = "success"` | +| Saisie utilisateur requise | `RUN_FINISHED` avec `outcome.type = "interrupt"` | +| Erreur de l’adaptateur ou d’A2A | `RUN_ERROR` | + +`RUN_FINISHED` termine une exécution AG-UI, pas nécessairement tout le Pipeline. Un Pipeline interrompu plusieurs fois possède plusieurs exécutions, chacune avec ses propres `RUN_STARTED` et `RUN_FINISHED`. La fin métier du Pipeline est indiquée par `pipeline_completed`, `pipeline_error` et les événements apparentés. + +Pour équilibrer les spans AG-UI, l’adaptateur ferme les messages, raisonnements, outils et étapes ouverts avant qu’une interruption ne termine l’exécution. La reprise rouvre les étapes durables encore actives. Une trace brute peut donc montrer la même étape métier se fermer dans une exécution puis se rouvrir dans la suivante ; l’ordre métier n’est pas inversé. + +## Événements personnalisés iac-code + +### `iac-code.session.v1` + +Expose l’association courante entre l’adaptateur et A2A : `threadId`, `aguiRunId`, `executionId`, `contextId`, `taskId`, `rosInvocationId` et `sessionId`. Utilisez `executionId` avec l’extension d’annulation. Un client générique peut ignorer cet événement. + +### `iac-code.artifact.v1` + +Transporte une projection structurée d’un artefact de tâche A2A, pour un aperçu, un téléchargement ou un diagnostic facultatif. + +### `iac-code.tool-progress.v1` + +Transporte la progression intermédiaire d’un outil sans équivalent standard. Le démarrage, les arguments et le résultat final restent des événements standard `TOOL_CALL_*` et ne sont pas dupliqués ici. + +### `iac-code.pipeline.v1` + +Seules les informations utiles sans équivalent standard complet sont émises. Valeurs `eventType` actuelles : + +- Pipeline : `pipeline_started`, `pipeline_resumed`, `pipeline_completed`, `pipeline_error`, `pipeline_warning`, `backup_blocked` ; +- candidats : `candidate_started`, `candidate_completed`, `candidate_failed`, `candidate_interrupted`, `candidate_restart_requested`, `candidate_selected`, `candidate_detail_shown`, `candidate_step_failed` ; +- sous-Pipelines et erreurs d’étape : `sub_pipeline_started`, `sub_pipeline_completed`, `sub_step_failed`, `step_failed` ; +- stacks et nettoyage : `stack_progress`, `stack_instances_progress`, `stack_current_changed`, `cleanup_started`, `cleanup_progress`, `cleanup_completed`, `cleanup_failed` ; +- rollback : `rollback_triggered`, `rollback_completed` ; +- contexte : `context_compaction_started`, `context_compacted`, `context_compaction_failed`, `fields_marked_stale` ; +- présentation et outils : `diagram_shown`, `mcp_status`, `tool_progress`. + +Les signaux disposant d’une correspondance standard ne sont pas dupliqués en `CUSTOM` : `text_delta` devient `TEXT_MESSAGE_*`, `thinking_delta` devient `REASONING_*`, `tool_started/tool_result` deviennent `TOOL_CALL_*`, `usage` devient `RUN_FINISHED.usage` et les cycles d’étapes deviennent `STEP_*`. + +Les clients devraient dédupliquer les événements de Pipeline rejoués avec `(name, value.eventId)` ou la séquence du Pipeline, et tolérer les événements personnalisés inconnus avec espace de noms. + +## Interruption + +Une exécution nécessitant une saisie se termine par `RUN_FINISHED.outcome.type = "interrupt"`. Chaque interruption contient : + +- `id` et `reason` ; +- un `message` destiné à l’utilisateur ; +- un `toolCallId` facultatif ; +- un `responseSchema` JSON ; +- `expiresAt` ; +- des métadonnées comme `title`, `purpose`, `safeSummary`, `options` et `toolName`. + +Pour une demande d’autorisation, le schéma accepte généralement : + +```json +{"decision": "allow_once"} +``` + +ou : + +```json +{"decision": "deny"} +``` + +Affichez `message`, `responseSchema` et les métadonnées descriptives au lieu de déduire l’interface uniquement depuis `reason`. Les questions et choix d’options peuvent utiliser d’autres schémas. + +## Reprise + +Une reprise est un nouveau `POST /` avec le même `threadId`, un nouveau `runId`, le même `rosInvocationId` et une entrée par interruption en attente : + +```json +{ + "resume": [ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + } + ] +} +``` + +Règles : + +- répondre exactement une fois à chaque interruption en attente ; +- les identifiants dupliqués ou inconnus sont refusés ; +- `resolved` exige un payload conforme au schéma ; +- `cancelled` arrête l’interruption et correspond à `deny` pour une autorisation ; +- l’état durable n’est supprimé qu’après acceptation par A2A ; +- une erreur de schéma produit `RUN_ERROR` sans empêcher une nouvelle tentative ; +- répéter une réponse déjà acceptée ne réexécute pas l’outil. + +Avant d’appliquer une reprise, l’adaptateur peut demander à A2A de restaurer la session iac-code, vérifie l’identité de la tâche et du contexte A2A, puis récupère les événements de Pipeline manquants. + +## Tours et identités + +```text +threadId (conversation stable) + ├─ runId-1 (tour utilisateur) + ├─ runId-2 (reprise d’interruption) + ├─ runId-3 (autre reprise) + └─ runId-4 (message normal suivant) +``` + +Chaque requête HTTP/SSE utilise un `runId` unique. Une reprise est une nouvelle exécution. Après un tour normal, le message suivant crée une nouvelle exécution tout en réutilisant la session iac-code du thread. L’idempotence est limitée à `(threadId, runId)`. + +## Extension d’annulation + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +Résultats possibles : `cancelled`, `already_terminal`, ou HTTP `404` avec `EXECUTION_NOT_FOUND`. L’annulation supprime les interruptions en attente sans modifier le format des événements AG-UI standard. + +## Persistance et reprise après arrêt + +Répertoire par défaut : + +```text +/agui/threads/.json +``` + +Chaque fichier contient la liaison thread/contexte/espace de travail, les identités de session, tâche et exécution, les positions de reprise du Pipeline, les interruptions en attente et les données d’idempotence. L’adaptateur charge à la demande un seul thread et remplace atomiquement uniquement son petit fichier. + +Les clés LLM, secrets AccessKey et jetons STS n’y sont jamais enregistrés. Ce répertoire sert aux associations de l’adaptateur, pas aux conversations ni aux artefacts. A2A gère sa propre persistance de sessions et de tâches ; consultez la [documentation A2A](../a2a/overview.md). + +Lors de l’accès suivant, une interruption expirée est refusée, son état en attente est supprimé et l’adaptateur tente d’annuler la tâche A2A correspondante. + +## Déconnexions + +- Une exécution terminée proprement par une interruption ne dépend plus de sa connexion SSE. +- Une reprise crée une nouvelle connexion SSE. +- Déconnecter une exécution ordinaire active conduit l’adaptateur à annuler la tâche A2A. +- Une déconnexion après une interruption ne supprime pas son état de reprise persistant. + +## Erreurs + +Les erreurs antérieures au démarrage de SSE utilisent une enveloppe JSON HTTP. Pendant l’exécution, elles utilisent les événements standard `RUN_ERROR`. + +| Code | Signification | +|------|---------------| +| `INVALID_INPUT` | Enveloppe, extension, message ou espace de travail invalide | +| `DUPLICATE_RUN_ID` | Même empreinte de requête avec un run ID existant | +| `RUN_ID_CONFLICT` | Une requête différente réutilise un run ID | +| `THREAD_BUSY` | Le thread exécute déjà une requête | +| `THREAD_BINDING_CONFLICT` | Espace de travail ou demandeur incompatible avec la liaison du thread | +| `RESUME_REQUIRED` | Le thread attend des réponses d’interruption | +| `INCOMPLETE_RESUME` | Interruptions manquantes ou identifiants dupliqués | +| `UNKNOWN_INTERRUPT` | Interruption inconnue dans la reprise | +| `RESUME_PAYLOAD_INVALID` | Payload absent ou non conforme au schéma | +| `RESUME_ALREADY_APPLIED` | Réponse déjà appliquée ou en conflit | +| `EXECUTION_EXPIRED` | Interruption expirée | +| `EXECUTION_LOST` | Impossible de restaurer l’adaptateur, la tâche A2A ou la session iac-code | +| `STATE_PERSISTENCE_FAILED` | Impossible de persister un état critique pour la reprise | +| `A2A_UNAVAILABLE` | Service d’exécution A2A local indisponible | +| `A2A_PROTOCOL_ERROR` | Identité tâche/contexte/session incompatible avec l’association | +| `A2A_EXECUTION_FAILED` | Échec de la tâche A2A | +| `CANCELLED` | Exécution annulée | + +Les écritures critiques pour la reprise échouent de manière sûre. L’adaptateur n’annonce pas une tâche, une session ou une interruption récupérable avant que son association soit persistée, et annule la tâche A2A correspondante si nécessaire. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current.json b/website/i18n/ja/docusaurus-plugin-content-docs/current.json index 9fb6f6dc..34e1ef9d 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "A2A プロトコル", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "AG-UI プロトコル", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "自動化", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..4b5e9de8 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,243 @@ +--- +sidebar_position: 2 +title: クイックスタート +description: iac-code AG-UI アダプターのインストール、起動、呼び出し。 +--- + +# AG-UI クイックスタート + +## 前提条件 + +1. Python 3.10 以降がインストール済みであること。 +2. iac-code の LLM provider が設定済みであること。[認証](../configuration/authentication.md)を参照してください。 +3. Alibaba Cloud を操作する場合は、クラウド認証情報を設定するか、リクエスト単位で一時認証情報を渡すこと。 +4. iac-code が読み書きできるワークスペースの絶対パスがあること。 + +AG-UI 依存関係をインストールします。 + +```bash +pip install "iac-code[agui]" +``` + +ソースリポジトリで開発する場合: + +```bash +uv sync --extra agui +``` + +## 方法 1:管理対象のローカル A2A カーネル + +最も簡単な方法は `--a2a-url` を省略することです。 + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +アダプターは空いているループバックポートを選び、`iac-code a2a` 子プロセスを起動し、終了時に停止します。子プロセスは現在の iac-code 設定と実行環境を継承します。 + +ローカル開発や単一ライフサイクル管理に適しています。2 つのプロセスを個別に管理する場合は次の方法を使います。 + +## 方法 2:独立した A2A カーネルへ接続 + +A2A server を起動します。 + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +次に AG-UI adapter を起動します。 + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +A2A は A2A クライアントへの提供を続けながら、AG-UI adapter からもループバック経由で利用できます。 + +`--thinking-exposure all` は raw thinking を標準 `REASONING_*` に変換できるようにします。信頼できるクライアントにのみ有効化してください。推論を公開しない場合は既定の `tool-trace` を使います。 + +A2A server が Bearer token を使う場合: + +```bash +export IACCODE_A2A_HTTP_TOKEN="a2a-local-secret" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +adapter に同じ upstream token を設定します。 + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="a2a-local-secret" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## YAML 設定 + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +```bash +iac-code agui --config agui-server.yml +``` + +明示した CLI 引数は YAML より優先されます。token などの機密値は設定ファイルではなく環境変数で渡すことを推奨します。 + +| CLI / YAML | 既定値 | 意味 | +|------------|--------|------| +| `--host` / `host` | `127.0.0.1` | AG-UI HTTP バインド先 | +| `--port` / `port` | `8000` | AG-UI ポート。例では `41243` | +| `--a2a-url` / `a2a-url` | 空 | ローカル A2A URL。空なら子プロセスを起動 | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Interrupt を Resume できる秒数 | +| `--state-dir` / `state-dir` | `/agui` | thread 状態ディレクトリ | +| `--idle-shutdown` / `idle-shutdown` | `0` | アイドル終了秒数。`0` は無効 | +| `--debug` / `debug` | `false` | デバッグログ | +| `--log-stdout` / `log-stdout` | `false` | stdout にもログを出力 | + +| 環境変数 | 用途 | +|----------|------| +| `IAC_CODE_AGUI_HOST` | バインド先 | +| `IAC_CODE_AGUI_PORT` | ポート | +| `IAC_CODE_AGUI_A2A_URL` | ローカル A2A upstream URL | +| `IAC_CODE_AGUI_A2A_TOKEN` | A2A upstream token | +| `IAC_CODE_AGUI_AUTH_TOKEN` | AG-UI endpoint を保護する token | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Interrupt 有効期間 | +| `IAC_CODE_AGUI_STATE_DIR` | thread 状態ディレクトリ | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | OS のパス区切りで列挙した許可ワークスペースルート | +| `IAC_CODE_CONFIG_DIR` | iac-code 設定ルートと既定状態ディレクトリの親 | + +## ヘルスチェック + +```bash +curl http://127.0.0.1:41243/health +``` + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "現在の iac-code バージョン" +} +``` + +## 公式 JavaScript client + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +標準 `HttpAgent` で `iac-code agui` へ直接接続し、`forwardedProps` に実行情報を渡します。 + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId: randomUUID(), + // IAC_CODE_AGUI_AUTH_TOKEN を設定した場合: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "ja", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "2 つの vSwitch を持つ VPC テンプレートを作成してください。", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[tool] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[step] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +Bearer token を使う場合は `HttpAgent.headers` で `Authorization` を渡します。ブラウザーは通常、同一オリジンのバックエンドまたはリバースプロキシ経由で接続します。adapter 自体は CORS を追加しません。 + +## Interrupt の処理 + +公式 client は Interrupt を `agent.pendingInterrupts` に保持します。各 `responseSchema` に従って回答し、新しい run で送信します。 + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +この payload は `decision` を要求する権限 Interrupt 専用です。質問や選択はそれぞれの schema に従ってください。 + +Resume では、元の `threadId`、新しい `runId`、中断時と同じ `rosInvocationId` を使い、保留中の全 Interrupt を 1 回ずつ回答します。`resolved` は schema に合う payload が必須で、続行しない場合は `cancelled` を使います。 + +## Pipeline の開始 + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +クライアントは `STEP_*`、`TOOL_CALL_*`、`ACTIVITY_SNAPSHOT`、`CUSTOM` を処理します。iac-code 独自イベントを知らない汎用クライアントも標準イベントは正常に利用できます。 + +## ワークスペースと一時認証情報 + +`cwd` はサーバー起動時には固定されず、リクエストごとに指定します。`IAC_CODE_AGUI_ALLOWED_CWDS` または `IACCODE_A2A_ALLOWED_CWDS` で許可されたルート配下の絶対パスでなければなりません。 + +モデル、LLM key、Alibaba Cloud 一時認証情報は `forwardedProps.iacCode` でリクエスト単位に渡せます。adapter はこれらを thread 状態へ保存せず、A2A 実行カーネルへ転送します。 + +## 状態ディレクトリ + +```text +/agui/ + threads/ + .json +``` + +thread ごとに独立して保存され、起動時に全履歴を走査しません。通常の UUID は読みやすいファイル名を維持し、安全でない ID はエンコードされ、長い ID には固定長キーを使います。JSON 内では元の `threadId` を常に保存・検証します。 + +ここに保存されるのは adapter の対応情報、Interrupt、冪等性状態だけです。会話本文や認証情報は含まれません。JSON を手動編集しないでください。 + +## 次のステップ + +- [AG-UI 概要](./overview.md) +- [プロトコルリファレンス](./protocol-reference.md) diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..d0910578 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,132 @@ +--- +sidebar_position: 1 +title: AG-UI プロトコル +description: iac-code の AG-UI 連携におけるアーキテクチャ、機能、利用場面。 +--- + +# AG-UI プロトコル + +## AG-UI とは + +[Agent-User Interaction Protocol(AG-UI)](https://docs.ag-ui.com/concepts/architecture) は、エージェントとユーザー向けアプリケーションをつなぐイベントストリームプロトコルです。クライアントは `RunAgentInput` で実行を開始し、HTTP Server-Sent Events(SSE)を通じて、テキスト、推論、ツール呼び出し、ステップ、状態、Interrupt を構造化イベントとして受信します。 + +AG-UI は、Web コンソール、チャットクライアント、IDE 拡張など、エージェントの実行状況をリアルタイム表示するアプリケーションに適しています。最終テキストだけでなく、モデル出力、ツール引数と結果、Pipeline ステップ、ユーザー確認待ちの操作を個別に描画できます。 + +## iac-code のアーキテクチャ + +iac-code は **A2A 実行カーネル + AG-UI プロトコルアダプター** という構成です。 + +```text +AG-UI client + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Agent loop / Pipeline / LLM / Alibaba Cloud API +``` + +`iac-code a2a` が唯一の実行カーネルであり、次を担当します。 + +- normal 会話と Pipeline 実行 +- iac-code session、A2A context、task +- ツール権限、質問、選択、復旧 +- 実行ライフサイクルとキャンセル +- LLM と Alibaba Cloud API の呼び出し + +`iac-code agui` は別の Agent runtime を生成せず、Pipeline も直接実行しません。担当範囲は次のとおりです。 + +- AG-UI `RunAgentInput` を A2A リクエストへ変換 +- A2A イベントを標準 AG-UI イベントへ投影 +- `threadId/runId` と A2A `contextId/taskId` の対応付け +- AG-UI `resume[]` を A2A 入力復旧へ変換 +- プロトコル対応情報と保留中 Interrupt の永続化 +- キャンセルの A2A への転送 + +このため、AG-UI と A2A が別々の実行仕様を持つことはありません。モデル、クラウド認証情報、権限ルール、Pipeline 動作は同じ A2A runtime が処理します。 + +## 標準プロトコルと iac-code 拡張 + +外部ストリームには次の標準 AG-UI イベントを使用します。 + +- `RUN_STARTED`、`RUN_FINISHED`、`RUN_ERROR` +- `TEXT_MESSAGE_*` +- `REASONING_*` +- `TOOL_CALL_*` +- `STEP_STARTED`、`STEP_FINISHED` +- `ACTIVITY_SNAPSHOT` + +標準イベントで表現できず、クライアント表示に必要な iac-code Pipeline 情報だけを、名前空間付き `CUSTOM` イベントとして送信します。汎用 AG-UI クライアントはこれらを無視しても、テキスト、ツール呼び出し、Interrupt、run ライフサイクルを正常に処理できます。 + +リクエストは標準 `RunAgentInput` です。ワークスペースや実行モードなど、iac-code に必要な情報は標準の `forwardedProps` に格納します。 + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "request-identity", + "cwd": "/absolute/workspace/path", + "runMode": "normal" + } + } +} +``` + +汎用 AG-UI クライアントは標準イベントをそのまま利用できます。ただし `iac-code agui` を直接呼び出す場合は、`forwardedProps.iacCode` に `cwd` などの実行情報を指定する必要があります。 + +## 対応する対話 + +### 複数ターンの normal 会話 + +会話全体で同じ `threadId` を使い、ユーザーターンごとに新しい `runId` を指定します。アダプターは thread を 1 つの iac-code session に関連付けます。次のメッセージは新しい HTTP/SSE リクエストで始まり、終了済みの SSE 応答を再利用しません。 + +### Pipeline + +`forwardedProps.iacCode.runMode` を `pipeline` に設定します。実行は A2A Pipeline カーネルが行います。最上位ステップは標準 `STEP_*`、テキスト、推論、ツールは対応する標準イベントになります。候補、スタック進捗、クリーンアップ進捗など標準表現のない情報は `iac-code.pipeline.v1` で送信されます。 + +並列 sub-pipeline は個別のメッセージ ID とステップ ID を使うため、複数 agent loop のテキストが 1 つに混在しません。 + +### Interrupt と Resume + +権限確認、質問、選択でユーザー入力が必要になると、現在の run は次のイベントで終了します。 + +```json +{ + "type": "RUN_FINISHED", + "outcome": {"type": "interrupt", "interrupts": []} +} +``` + +Interrupt はクライアントへ通知する前に永続化されます。クライアントは回答を集め、同じ `threadId`、新しい `runId`、`resume[]` で新規リクエストを開始します。Resume の SSE は新しいリクエストに属し、古いストリームへ再接続するものではありません。 + +### アダプター状態 + +アダプターは thread ごとに、プロトコル対応情報、冪等性情報、保留中 Interrupt を保存します。このディレクトリには会話本文、LLM キー、クラウド認証情報は保存されず、会話のエクスポート先でもありません。 + +## AG-UI を選ぶ場面 + +| 要件 | 推奨モード | +|------|------------| +| テキスト、推論、ツール、ステップをリアルタイム表示するチャット UI | **AG-UI** | +| UI で権限、質問、選択を処理 | **AG-UI** | +| 別のエージェントやオーケストレーターから直接呼び出す | **A2A** | +| IDE/エディターで ACP session やターミナル機能を利用 | **ACP** | +| ローカルで手動操作 | **対話 REPL または Web/Desktop** | + +AG-UI と A2A は同時に起動できます。HTTP エンドポイントは別ですが、同じ iac-code 実装を利用します。 + +## 現在の制約 + +- AG-UI のトランスポートは HTTP POST + SSE です。 +- A2A upstream はループバックアドレスに限られ、任意のリモート A2A URL には接続できません。 +- `cwd` はリクエストごとに必須で、許可されたワークスペースルート配下でなければなりません。 +- クライアント定義の `tools` は現在未対応です。ツール集合は iac-code が管理します。 +- ユーザーメッセージはテキストとインライン base64 画像に対応し、リモートメディア URL には対応しません。 +- Interrupt 前の実行中 SSE をクライアントが切断すると、対応する A2A task はキャンセルされます。 +- SSE は 15 秒ごとにコメント形式の heartbeat を送信し、準拠クライアントはこれを無視します。 + +## 次に読むページ + +- [クイックスタート](./getting-started.md) +- [プロトコルリファレンス](./protocol-reference.md) diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..bdeb8cc5 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,229 @@ +--- +sidebar_position: 3 +title: プロトコルリファレンス +description: iac-code AG-UI のリクエスト、イベント、Interrupt、Resume、キャンセル、永続化。 +--- + +# AG-UI プロトコルリファレンス + +`iac-code agui` の HTTP/SSE インターフェースと、標準 AG-UI envelope 内の iac-code 拡張を説明します。先に[概要](./overview.md)と[クイックスタート](./getting-started.md)を参照してください。 + +## HTTP エンドポイント + +| メソッドとパス | 用途 | +|----------------|------| +| `GET /health` | ヘルスとプロトコルバージョン | +| `POST /` | `RunAgentInput` を送信し SSE を受信 | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | 名前空間付きキャンセル拡張 | + +`POST /` は JSON で送信し、SSE を要求します。 + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +`IAC_CODE_AGUI_AUTH_TOKEN` を設定した場合: + +```http +Authorization: Bearer +``` + +標準 `Accept-Language` はエラーメッセージ言語のフォールバックです。`forwardedProps.iacCode.preferredLanguage` が優先され、A2A runtime にも転送されます。 + +## RunAgentInput + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [{"id": "message-1", "role": "user", "content": "VPC テンプレートを作成"}], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +| 標準フィールド | 要件と動作 | +|----------------|------------| +| `threadId` | 必須。会話中安定し、A2A context と iac-code session に対応 | +| `runId` | 必須。HTTP/SSE 実行ごとに一意 | +| `parentRunId` | 任意。`RUN_STARTED` へコピー | +| `state` | 必須。標準 envelope に保持するが runtime 状態源にはしない | +| `messages` | 必須。新規 run は最新 user message を使用 | +| `tools` | 必須かつ空配列。クライアント定義ツールは未対応 | +| `context` | 必須。現在は prompt context へ変換しない | +| `forwardedProps` | 必須。`iacCode` 拡張を含める | +| `resume` | Resume 時に使用。保留中 Interrupt ごとの回答 | + +ユーザーメッセージは文字列、`text` part、base64 `data` source の `image` part に対応します。リモート画像 URL、音声、動画、document、汎用 binary は未対応です。画像 1 件はデコード後 8 MiB、合計 10 MiB、HTTP リクエスト全体は 12 MiB が上限です。 + +## `forwardedProps.iacCode` + +未知フィールドを拒否する厳密な schema です。 + +| フィールド | 型 | 必須 | 意味 | +|------------|----|------|------| +| `schemaVersion` | `1` | はい | 拡張バージョン | +| `rosInvocationId` | string | はい | execution 呼び出し識別子。最大 256 文字 | +| `cwd` | string | はい | ワークスペース絶対パス | +| `model` | string | いいえ | リクエスト単位のモデル上書き | +| `llmApiKey` | string | いいえ | LLM provider key | +| `thinking.enabled/effort/budget` | boolean/string/正整数 | いいえ | thinking 設定 | +| `userId` | string | いいえ | telemetry と呼び出し元の識別 | +| `channel` | string | いいえ | チャネルメタデータ | +| `preferredLanguage` | string | いいえ | ユーザー向け言語(例:`ja`) | +| `candidatePresentation` | `standard` / `rich` | いいえ | Pipeline 候補の表示形式 | +| `runMode` | `normal` / `pipeline` | いいえ | 実行モード | +| `pipelineName` | string | いいえ | Pipeline 名 | +| `cleanupOnly` | boolean | いいえ | クリーンアップのみを要求 | +| `alibabaCloud.accessKeyId` | string | いいえ | 一時 AccessKey ID | +| `alibabaCloud.accessKeySecret` | string | いいえ | 一時 AccessKey Secret | +| `alibabaCloud.securityToken` | string | いいえ | 一時 STS token | +| `alibabaCloud.regionId` | string | いいえ | 既定 region | + +initial run とその Resume は同じ `rosInvocationId` を使います。次の通常ターンでは新しい値を利用できます。Cancel も現在の値が必要です。 + +同じ `threadId` は最初の `cwd` と `userId` に固定され、後続リクエストで別のワークスペースや呼び出し元へ変更できません。 + +## SSE と heartbeat + +各イベントは SSE `data:` レコードです。15 秒イベントがなければ次のコメントを送信します。 + +```text +: heartbeat +``` + +これは AG-UI `CUSTOM` ではありません。SSE クライアントは無視しつつ、HTTP 接続の維持に利用します。 + +## 標準イベント対応 + +| iac-code/A2A 信号 | AG-UI | +|-------------------|-------| +| リクエスト受付 | `RUN_STARTED` | +| agent テキスト | `TEXT_MESSAGE_*` | +| raw thinking | `REASONING_*` | +| ツール開始・引数 | `TOOL_CALL_START/ARGS/END` | +| ツール結果 | `TOOL_CALL_RESULT` | +| Pipeline step | `STEP_STARTED/STEP_FINISHED` | +| Pipeline 復旧スナップショット | `ACTIVITY_SNAPSHOT` | +| 正常終了 | success の `RUN_FINISHED` | +| 入力待ち | interrupt の `RUN_FINISHED` | +| エラー | `RUN_ERROR` | + +`RUN_FINISHED` は AG-UI run 1 回の終了であり、Pipeline 全体の終了とは限りません。複数 Interrupt がある Pipeline では複数 run が生じます。Pipeline の業務終端は `pipeline_completed`、`pipeline_error` などで表します。 + +AG-UI span の整合性を保つため、Interrupt 前に開いている message、reasoning、tool、step を閉じ、Resume の新 run で継続中の step を再度開きます。同じ業務 step が run 間で一度閉じて再開して見えるのは逆順実行ではありません。 + +## iac-code カスタムイベント + +- `iac-code.session.v1`:`threadId`、`executionId`、`contextId`、`taskId`、`sessionId` などの対応情報。`executionId` は Cancel に使用できます。 +- `iac-code.artifact.v1`:A2A task artifact の構造化投影。 +- `iac-code.tool-progress.v1`:標準イベントにないツール中間進捗。開始、引数、結果は標準 `TOOL_CALL_*` のままです。 +- `iac-code.pipeline.v1`:標準表現のない有用な Pipeline 情報。 + +`iac-code.pipeline.v1` の `eventType`: + +- Pipeline:`pipeline_started`、`pipeline_resumed`、`pipeline_completed`、`pipeline_error`、`pipeline_warning`、`backup_blocked` +- 候補:`candidate_started`、`candidate_completed`、`candidate_failed`、`candidate_interrupted`、`candidate_restart_requested`、`candidate_selected`、`candidate_detail_shown`、`candidate_step_failed` +- sub-pipeline:`sub_pipeline_started`、`sub_pipeline_completed`、`sub_step_failed`、`step_failed` +- スタックとクリーンアップ:`stack_progress`、`stack_instances_progress`、`stack_current_changed`、`cleanup_started`、`cleanup_progress`、`cleanup_completed`、`cleanup_failed` +- rollback:`rollback_triggered`、`rollback_completed` +- context:`context_compaction_started`、`context_compacted`、`context_compaction_failed`、`fields_marked_stale` +- 表示とツール:`diagram_shown`、`mcp_status`、`tool_progress` + +`text_delta`、`thinking_delta`、`tool_started/tool_result`、`usage`、step lifecycle は標準イベントに変換されるため `CUSTOM` では重複送信しません。再送イベントは `(name, value.eventId)` または sequence で重複排除してください。 + +## Interrupt と Resume + +入力待ち run は `RUN_FINISHED.outcome.type = "interrupt"` で終了します。Interrupt には `id`、`reason`、ユーザー向け `message`、任意の `toolCallId`、JSON `responseSchema`、`expiresAt`、`title/purpose/safeSummary/options/toolName` などの metadata が含まれます。 + +権限確認の例: + +```json +{"decision": "allow_once"} +``` + +または: + +```json +{"decision": "deny"} +``` + +UI は `reason` だけで推測せず、`message`、`responseSchema`、説明 metadata を利用してください。 + +Resume は同じ `threadId`、新しい `runId`、同じ `rosInvocationId` で、新しい `POST /` として送信します。 + +```json +{ + "resume": [{ + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + }] +} +``` + +全 pending Interrupt を 1 回ずつ回答し、重複・未知 ID は使用できません。`resolved` の payload は schema に一致する必要があります。`cancelled` はその Interrupt を中止し、権限では `deny` として扱われます。schema エラーは `RUN_ERROR` となりますが、Interrupt は再試行可能なままです。受理済み回答を再送してもツールは再実行されません。 + +## turn と識別子 + +```text +threadId(会話全体で固定) + ├─ runId-1(ユーザーターン) + ├─ runId-2(Interrupt Resume) + ├─ runId-3(次の Resume) + └─ runId-4(次の通常メッセージ) +``` + +HTTP/SSE リクエストごとに一意の `runId` を使います。冪等性の範囲は `(threadId, runId)` です。 + +## キャンセル拡張 + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +結果は `cancelled`、`already_terminal`、または HTTP `404` の `EXECUTION_NOT_FOUND` です。Cancel は pending Interrupt を消去しますが、標準イベント形式は変更しません。 + +## 永続化と切断 + +状態は既定で `/agui/threads/.json` に保存されます。thread 対応、session/task/execution ID、Pipeline 復旧位置、pending Interrupt、冪等性情報を含みます。要求された thread だけを遅延読み込みし、その小さなファイルだけを原子的に置き換えます。 + +LLM key、AccessKey Secret、STS token、会話本文、実行成果物は保存しません。A2A の session/task 永続化は A2A server が管理します。[A2A ドキュメント](../a2a/overview.md)を参照してください。 + +期限切れ Interrupt は次回アクセス時に拒否・消去され、対応 A2A task のキャンセルを試みます。Interrupt で安全終了した run は古い SSE を必要としません。通常の実行中に切断すると A2A task をキャンセルします。 + +## エラー + +SSE 前のエラーは HTTP JSON、実行中のエラーは `RUN_ERROR` です。主な code: + +| code | 意味 | +|------|------| +| `INVALID_INPUT` | envelope、拡張、メッセージ、workspace が無効 | +| `DUPLICATE_RUN_ID` / `RUN_ID_CONFLICT` | run ID の再利用 | +| `THREAD_BUSY` | thread が実行中 | +| `THREAD_BINDING_CONFLICT` | workspace または caller が既存 binding と不一致 | +| `RESUME_REQUIRED` | Interrupt 回答待ち | +| `INCOMPLETE_RESUME` / `UNKNOWN_INTERRUPT` | Resume の不足、重複、未知 ID | +| `RESUME_PAYLOAD_INVALID` | payload が schema と不一致 | +| `RESUME_ALREADY_APPLIED` | 回答を適用済み | +| `EXECUTION_EXPIRED` / `EXECUTION_LOST` | execution が期限切れまたは復旧不能 | +| `STATE_PERSISTENCE_FAILED` | 重要状態を書き込めない | +| `A2A_UNAVAILABLE` / `A2A_PROTOCOL_ERROR` / `A2A_EXECUTION_FAILED` | A2A の接続、ID、実行エラー | +| `CANCELLED` | execution がキャンセル済み | + +復旧に必要な書き込みは fail closed です。永続化前に復旧可能だと通知せず、必要に応じて A2A task をキャンセルします。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current.json b/website/i18n/pt/docusaurus-plugin-content-docs/current.json index 490d12e7..7564b35c 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "Protocolo A2A", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "Protocolo AG-UI", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "Automacao", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..b86aac69 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,259 @@ +--- +sidebar_position: 2 +title: Primeiros passos +description: Instale, inicie e chame o adaptador AG-UI do iac-code. +--- + +# Primeiros passos com AG-UI + +## Pré-requisitos + +1. Python 3.10 ou posterior está instalado. +2. Um provedor de LLM está configurado para o iac-code. Consulte [Autenticação](../configuration/authentication.md). +3. Se a tarefa acessar o Alibaba Cloud, configure credenciais de nuvem ou forneça credenciais temporárias em cada solicitação. +4. Há um caminho absoluto de workspace disponível para leitura e gravação pelo iac-code. + +Instale as dependências AG-UI: + +```bash +pip install "iac-code[agui]" +``` + +Para desenvolver a partir do repositório-fonte: + +```bash +uv sync --extra agui +``` + +## Opção 1: iniciar um núcleo A2A local gerenciado + +Para a configuração local mais simples, omita `--a2a-url`: + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +O adaptador escolhe uma porta de loopback disponível, inicia um processo filho `iac-code a2a` gerenciado e o encerra quando o adaptador termina. O filho herda a configuração e o ambiente de runtime atuais do iac-code. + +Esse modo é adequado para desenvolvimento local e gerenciamento conjunto do ciclo de vida. Use a opção seguinte quando o supervisor de produção precisar gerenciar os dois serviços separadamente. + +## Opção 2: conectar a um núcleo A2A independente + +Primeiro, inicie o servidor A2A: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +Depois, inicie o adaptador AG-UI: + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +Os serviços mantêm responsabilidades e portas separadas. O A2A pode continuar atendendo clientes A2A, enquanto o adaptador o acessa apenas pela interface de loopback. + +`--thinking-exposure all` permite converter o raciocínio bruto em eventos padrão `REASONING_*`. Habilite-o somente para clientes confiáveis. Mantenha o padrão A2A, `tool-trace`, quando o conteúdo de raciocínio não deve ser exposto. + +Se o servidor A2A usar um token bearer: + +```bash +export IACCODE_A2A_HTTP_TOKEN="segredo-a2a-local" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Forneça ao adaptador o mesmo token do upstream: + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="segredo-a2a-local" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## Configuração YAML + +Configurações estáticas de inicialização podem ser armazenadas em YAML: + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +Inicie o adaptador com: + +```bash +iac-code agui --config agui-server.yml +``` + +Argumentos explícitos da CLI substituem o YAML. Injete valores confidenciais, como tokens, por variáveis de ambiente em vez de armazená-los no arquivo. + +| CLI / YAML | Padrão | Significado | +|------------|--------|-------------| +| `--host` / `host` | `127.0.0.1` | Endereço HTTP de escuta do AG-UI | +| `--port` / `port` | `8000` | Porta HTTP do AG-UI; os exemplos de implantação usam `41243` | +| `--a2a-url` / `a2a-url` | vazio | URL A2A local; vazio inicia um filho gerenciado | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Segundos durante os quais uma interrupção pode ser retomada | +| `--state-dir` / `state-dir` | `/agui` | Diretório de estado dos threads AG-UI | +| `--idle-shutdown` / `idle-shutdown` | `0` | Atraso para desligamento ocioso; `0` o desabilita | +| `--debug` / `debug` | `false` | Logs de depuração | +| `--log-stdout` / `log-stdout` | `false` | Repetir os logs em stdout | + +Variáveis de ambiente relacionadas: + +| Variável | Finalidade | +|----------|------------| +| `IAC_CODE_AGUI_HOST` | Endereço de escuta do AG-UI | +| `IAC_CODE_AGUI_PORT` | Porta do AG-UI | +| `IAC_CODE_AGUI_A2A_URL` | URL do upstream A2A local | +| `IAC_CODE_AGUI_A2A_TOKEN` | Token bearer do upstream A2A | +| `IAC_CODE_AGUI_AUTH_TOKEN` | Token bearer que protege o endpoint AG-UI | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Vida útil da interrupção | +| `IAC_CODE_AGUI_STATE_DIR` | Diretório de estado dos threads AG-UI | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | Raízes de workspace permitidas, separadas pelo separador de caminhos do sistema operacional | +| `IAC_CODE_CONFIG_DIR` | Raiz de configuração do iac-code e diretório pai padrão do estado AG-UI | + +## Verificação de integridade + +```bash +curl http://127.0.0.1:41243/health +``` + +Exemplo de resposta: + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "versão atual do iac-code" +} +``` + +## Usar o cliente JavaScript oficial + +Instale a versão de cliente verificada: + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +Este exemplo se conecta diretamente a `iac-code agui`, usa o `HttpAgent` padrão e fornece as propriedades de runtime em `forwardedProps`: + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const threadId = randomUUID(); +const rosInvocationId = randomUUID(); +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId, + // Quando IAC_CODE_AGUI_AUTH_TOKEN estiver configurado: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId, + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "pt", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "Crie um modelo de VPC com dois vSwitches.", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[ferramenta] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[etapa] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +Quando houver token bearer, passe `Authorization` em `HttpAgent.headers`. Uma aplicação web normalmente se conecta por um backend de mesma origem ou proxy reverso; o adaptador não adiciona uma política CORS. + +## Tratar interrupções + +O cliente oficial mantém `RUN_FINISHED.outcome.interrupts` em `agent.pendingInterrupts`. Construa cada resposta a partir de seu `responseSchema` e envie-a em uma nova execução: + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent({ forwardedProps, resume: responses }, subscriber); +``` + +Esse payload se aplica apenas a interrupções de permissão cujo esquema exige `decision`. Perguntas e seleção de opções têm esquemas próprios. + +Uma retomada deve usar o `threadId` original, um novo `runId`, manter o `rosInvocationId` da execução interrompida, responder a todas as interrupções pendentes em uma única solicitação e fornecer um payload compatível com cada `responseSchema`. Use `status: "cancelled"` quando o usuário não quiser continuar. + +## Iniciar um Pipeline + +Defina `runMode` como `pipeline` e, opcionalmente, selecione um Pipeline: + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +Clientes devem tratar `STEP_*`, `TOOL_CALL_*`, `ACTIVITY_SNAPSHOT` e `CUSTOM`. Um cliente genérico que não reconheça eventos personalizados do iac-code ainda processa normalmente todos os eventos padrão. + +## Workspace e credenciais temporárias + +`cwd` não é fixado na inicialização do servidor. Cada solicitação deve fornecer um caminho absoluto sob uma raiz permitida por `IAC_CODE_AGUI_ALLOWED_CWDS` ou `IACCODE_A2A_ALLOWED_CWDS`. + +O chamador pode fornecer, por solicitação, um modelo, uma chave de LLM e credenciais temporárias do Alibaba Cloud por `forwardedProps.iacCode`. O adaptador não grava esses segredos no estado do thread; ele os encaminha ao núcleo A2A conforme as regras normais de substituição de solicitação. + +## Diretório de estado + +Estrutura padrão: + +```text +/agui/ + threads/ + .json +``` + +Cada thread é gravado independentemente, e a inicialização não percorre threads históricos. UUIDs normais continuam legíveis. IDs inseguros são codificados, e IDs muito longos usam uma chave de arquivo de tamanho fixo. O documento JSON sempre armazena e valida o `threadId` original. + +Esse diretório armazena apenas mapeamentos, interrupções e estado de idempotência do adaptador. Ele não contém conversas nem credenciais de solicitações. Não edite os arquivos JSON manualmente. + +## Próximos passos + +- [Visão geral do AG-UI](./overview.md) +- [Referência do protocolo](./protocol-reference.md) diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..f15bedb2 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,135 @@ +--- +sidebar_position: 1 +title: Protocolo AG-UI +description: Arquitetura, recursos e casos de uso da integração AG-UI do iac-code. +--- + +# Protocolo AG-UI + +## O que é AG-UI? + +O [Agent-User Interaction Protocol (AG-UI)](https://docs.ag-ui.com/concepts/architecture) é um protocolo de fluxo de eventos entre agentes e aplicações voltadas ao usuário. Um cliente inicia uma execução com `RunAgentInput` e recebe, por HTTP Server-Sent Events (SSE), eventos estruturados de texto, raciocínio, chamadas de ferramentas, etapas, estado e interrupções. + +AG-UI é adequado para consoles web, clientes de chat, extensões de IDE e outras aplicações que precisam mostrar a execução do agente em tempo real. Em vez de consumir apenas o texto final, um cliente pode renderizar separadamente a saída do modelo, argumentos e resultados de ferramentas, etapas do Pipeline e operações aguardando confirmação. + +## Arquitetura do iac-code + +O iac-code usa um **núcleo de execução A2A com um adaptador do protocolo AG-UI**: + +```text +Cliente AG-UI + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Loop do agente / Pipeline / LLM / API do Alibaba Cloud +``` + +`iac-code a2a` é o único núcleo de execução. Ele é responsável por: + +- conversas normais e execução de Pipelines; +- sessões do iac-code, contextos e tarefas A2A; +- permissões de ferramentas, perguntas, seleção de opções e retomada; +- ciclo de vida e cancelamento das execuções; +- chamadas a LLMs e APIs do Alibaba Cloud. + +`iac-code agui` não cria um segundo runtime do Agent nem executa Pipelines diretamente. Ele apenas: + +- converte `RunAgentInput` em solicitações A2A; +- projeta eventos A2A em eventos AG-UI padrão; +- mapeia `threadId/runId` para `contextId/taskId`; +- converte `resume[]` em retomada de entrada A2A; +- persiste os mapeamentos de protocolo e as interrupções pendentes; +- encaminha cancelamentos ao A2A. + +Assim, AG-UI e A2A não mantêm semânticas de execução separadas. Seleção de modelo, credenciais de nuvem, regras de permissão e comportamento do Pipeline são tratados pelo mesmo runtime A2A. + +## Protocolo padrão e extensões do iac-code + +O fluxo externo usa eventos AG-UI padrão, incluindo: + +- `RUN_STARTED`, `RUN_FINISHED` e `RUN_ERROR`; +- `TEXT_MESSAGE_*`; +- `REASONING_*`; +- `TOOL_CALL_*`; +- `STEP_STARTED` e `STEP_FINISHED`; +- `ACTIVITY_SNAPSHOT`. + +Somente informações úteis do Pipeline que não têm equivalente padrão são emitidas como eventos `CUSTOM` com namespace. Um cliente AG-UI genérico pode ignorá-los sem afetar texto, ferramentas, interrupções ou o ciclo de vida da execução. + +As solicitações continuam usando envelopes `RunAgentInput` padrão. O iac-code utiliza o campo padrão `forwardedProps` para o workspace, o modo de execução e outros dados necessários: + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "identidade-da-solicitacao", + "cwd": "/caminho/absoluto/do/workspace", + "runMode": "normal" + } + } +} +``` + +Um cliente genérico pode consumir diretamente os eventos padrão do iac-code. Ao chamar `iac-code agui` diretamente, ainda precisa fornecer campos de runtime como `cwd` em `forwardedProps.iacCode`. + +## Interações compatíveis + +### Conversas normais com vários turnos + +Mantenha o mesmo `threadId` durante a conversa e use um novo `runId` para cada turno do usuário. O adaptador vincula o thread a uma sessão do iac-code. Quando um turno termina, a próxima mensagem abre uma nova solicitação HTTP/SSE; ela nunca continua na resposta SSE anterior, já encerrada. + +### Pipeline + +Defina `forwardedProps.iacCode.runMode` como `pipeline`. O núcleo A2A continua executando o Pipeline. Etapas de nível superior se tornam eventos padrão `STEP_*`, e texto, raciocínio e ferramentas usam seus eventos padrão. Informações de candidatos, progresso de stacks e limpeza sem equivalente padrão são emitidas por `iac-code.pipeline.v1`. + +Sub-Pipelines paralelos usam identidades distintas para mensagens e etapas, evitando que o texto de vários loops de agente seja combinado. + +### Interrupção e retomada + +Quando uma permissão, pergunta ou seleção exige entrada do usuário, a execução atual termina com: + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [] + } +} +``` + +A interrupção é persistida antes de ficar visível ao cliente. Depois de coletar as respostas, o cliente inicia uma nova solicitação com o mesmo `threadId`, um novo `runId` e `resume[]`. O fluxo de retomada pertence a essa nova solicitação e não se reconecta ao fluxo antigo. + +### Estado do adaptador + +O adaptador armazena mapeamentos de protocolo, dados de idempotência e interrupções pendentes em um arquivo por thread. Esse diretório não contém texto de conversa, chaves de LLM ou credenciais de nuvem, e não é um diretório de exportação de conversas. + +## Quando usar AG-UI + +| Requisito | Modo recomendado | +|-----------|------------------| +| Criar uma interface de chat com texto, raciocínio, ferramentas e etapas em tempo real | **AG-UI** | +| Tratar permissões, perguntas e seleção de opções em uma interface | **AG-UI** | +| Permitir que outro agente ou orquestrador chame o iac-code diretamente | **A2A** | +| Integrar um IDE/editor com sessões ACP e recursos de terminal | **ACP** | +| Operar o iac-code manualmente | **REPL interativo ou Web/Desktop** | + +AG-UI e A2A podem ser executados ao mesmo tempo. Eles expõem endpoints HTTP separados e compartilham a mesma implementação de execução do iac-code. + +## Limites atuais + +- O transporte AG-UI usa HTTP POST e SSE. +- O upstream A2A deve usar um endereço de loopback; o adaptador rejeita URLs A2A remotas arbitrárias. +- `cwd` é obrigatório em cada solicitação e deve estar sob uma raiz de workspace permitida. +- `tools` definidos pelo cliente ainda não são aceitos; o iac-code controla o conjunto de ferramentas. +- Mensagens do usuário aceitam texto e imagens base64 incorporadas, mas não URLs de mídia remota. +- Se o cliente se desconectar de uma execução SSE ativa antes de uma interrupção, o adaptador cancela a tarefa A2A correspondente. +- O fluxo SSE envia um comentário heartbeat a cada 15 segundos. Clientes compatíveis o ignoram. + +## Próximos passos + +- [Primeiros passos](./getting-started.md) — instale, inicie e conecte o primeiro cliente. +- [Referência do protocolo](./protocol-reference.md) — campos, eventos, interrupção/retomada, persistência e erros. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..468b9acc --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,287 @@ +--- +sidebar_position: 3 +title: Referência do protocolo +description: Referência de solicitações, eventos, interrupções, retomada, cancelamento e persistência AG-UI do iac-code. +--- + +# Referência do protocolo AG-UI + +Esta página descreve a interface HTTP/SSE exposta por `iac-code agui` e os campos de extensão do iac-code transportados em envelopes AG-UI padrão. Consulte primeiro a [visão geral](./overview.md) e os [primeiros passos](./getting-started.md). + +## Endpoints HTTP + +| Método e caminho | Finalidade | +|------------------|------------| +| `GET /health` | Integridade do serviço e versões do protocolo | +| `POST /` | Enviar `RunAgentInput` e receber um fluxo de eventos SSE | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | Extensão de cancelamento com namespace | + +O corpo de `POST /` deve usar JSON, e clientes devem solicitar SSE: + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +Quando `IAC_CODE_AGUI_AUTH_TOKEN` estiver configurado, solicitações protegidas também exigem: + +```http +Authorization: Bearer +``` + +Use o cabeçalho padrão `Accept-Language` como alternativa para o idioma das mensagens de erro. `forwardedProps.iacCode.preferredLanguage` tem prioridade e também é encaminhado ao runtime A2A. + +## RunAgentInput + +Exemplo mínimo de execução normal: + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [ + {"id": "message-1", "role": "user", "content": "Crie um modelo de VPC"} + ], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +### Campos padrão + +| Campo | Requisito | Comportamento do iac-code | +|-------|-----------|---------------------------| +| `threadId` | String não vazia obrigatória | Identidade estável da conversa, mapeada para um contexto A2A e uma sessão do iac-code | +| `runId` | String não vazia obrigatória | Uma execução HTTP/SSE; não pode ser reutilizada no thread | +| `parentRunId` | Opcional | Copiado para `RUN_STARTED` | +| `state` | Obrigatório | Mantido no envelope padrão, mas não usado como estado de runtime do iac-code | +| `messages` | Obrigatório | Nova execução usa a última mensagem do usuário; uma retomada não precisa adicionar outra | +| `tools` | Obrigatório e vazio | Ferramentas definidas pelo cliente não são compatíveis | +| `context` | Obrigatório | Mantido no envelope; ainda não convertido em contexto do prompt | +| `forwardedProps` | Obrigatório | Deve conter a extensão `iacCode` | +| `resume` | Para retomada | Uma resposta para cada interrupção pendente | + +Mensagens do usuário aceitam strings e partes `text` e `image` com fontes `data` base64 incorporadas. URLs de imagem remota, áudio, vídeo, documentos e binários genéricos não são compatíveis. Uma imagem decodificada é limitada a 8 MiB, todas as imagens a 10 MiB e a solicitação HTTP completa a 12 MiB. + +## `forwardedProps.iacCode` + +O esquema é estrito; campos desconhecidos são rejeitados. + +| Campo | Tipo | Obrigatório | Significado | +|-------|------|-------------|-------------| +| `schemaVersion` | `1` | Sim | Versão da extensão do iac-code | +| `rosInvocationId` | string | Sim | Identidade do chamador da execução atual, até 256 caracteres | +| `cwd` | string | Sim | Caminho absoluto do workspace | +| `model` | string | Não | Substituição do modelo para a solicitação | +| `llmApiKey` | string | Não | Chave do provedor LLM para a solicitação | +| `thinking.enabled` | booleano | Não | Solicitar saída de raciocínio | +| `thinking.effort` | string | Não | Esforço de raciocínio específico do provedor | +| `thinking.budget` | inteiro positivo | Não | Orçamento de raciocínio específico do provedor | +| `userId` | string | Não | Identidade de telemetria e vínculo do chamador | +| `channel` | string | Não | Metadados do canal chamador | +| `preferredLanguage` | string | Não | Idioma de exibição local à solicitação, como `pt` | +| `candidatePresentation` | `standard` ou `rich` | Não | Apresentação dos candidatos do Pipeline | +| `runMode` | `normal` ou `pipeline` | Não | Modo de execução; caso contrário, escolhido pelo A2A | +| `pipelineName` | string | Não | Nome do Pipeline, por exemplo `selling` | +| `cleanupOnly` | booleano | Não | Solicitar apenas o caminho de limpeza do Pipeline | +| `alibabaCloud.accessKeyId` | string | Não | AccessKey ID local à solicitação | +| `alibabaCloud.accessKeySecret` | string | Não | Segredo AccessKey local à solicitação | +| `alibabaCloud.securityToken` | string | Não | Token STS local à solicitação | +| `alibabaCloud.regionId` | string | Não | Região padrão local à solicitação | + +A execução inicial e suas retomadas devem manter o mesmo `rosInvocationId`. Um turno normal posterior pode usar um novo valor. O cancelamento deve usar o valor da execução atual. + +O `threadId` é vinculado aos `cwd` e `userId` da primeira solicitação; solicitações posteriores não podem mover o mesmo thread para outro workspace ou chamador. + +## SSE e heartbeat + +Cada evento AG-UI é emitido como um registro SSE `data:`. Após 15 segundos sem eventos, o servidor emite: + +```text +: heartbeat +``` + +Esse é um comentário SSE, não um evento AG-UI `CUSTOM`. Clientes compatíveis o ignoram enquanto ele mantém a conexão HTTP ativa. + +## Mapeamento de eventos padrão + +| Sinal A2A/iac-code | Saída AG-UI | +|--------------------|-------------| +| Solicitação aceita | `RUN_STARTED` | +| Texto do agente | `TEXT_MESSAGE_START/CONTENT/END` | +| Raciocínio bruto | `REASONING_START`, `REASONING_MESSAGE_*`, `REASONING_END` | +| Início e argumentos da ferramenta | `TOOL_CALL_START/ARGS/END` | +| Resultado da ferramenta | `TOOL_CALL_RESULT` | +| Ciclo de vida da etapa do Pipeline | `STEP_STARTED/STEP_FINISHED` | +| Snapshot de recuperação do Pipeline | `ACTIVITY_SNAPSHOT` | +| Conclusão normal | `RUN_FINISHED` com `outcome.type = "success"` | +| Entrada do usuário necessária | `RUN_FINISHED` com `outcome.type = "interrupt"` | +| Erro do adaptador ou A2A | `RUN_ERROR` | + +`RUN_FINISHED` encerra uma execução AG-UI, não necessariamente todo o Pipeline. Um Pipeline interrompido várias vezes possui várias execuções, cada uma com seus próprios `RUN_STARTED` e `RUN_FINISHED`. A conclusão funcional do Pipeline é representada por `pipeline_completed`, `pipeline_error` e eventos relacionados. + +Para manter os spans AG-UI equilibrados, o adaptador fecha spans abertos de mensagem, raciocínio, ferramenta e etapa antes de uma interrupção encerrar a execução. A retomada reabre etapas duráveis do Pipeline que ainda estão ativas. Por isso, a inspeção de eventos brutos pode mostrar a mesma etapa funcional encerrando em uma execução e reabrindo na seguinte; isso não significa execução invertida. + +## Eventos personalizados do iac-code + +### `iac-code.session.v1` + +Expõe o mapeamento atual entre adaptador e A2A, incluindo `threadId`, `aguiRunId`, `executionId`, `contextId`, `taskId`, `rosInvocationId` e `sessionId`. Use `executionId` na extensão de cancelamento. Clientes genéricos podem ignorar esse evento. + +### `iac-code.artifact.v1` + +Transporta uma projeção estruturada de um artefato de tarefa A2A para visualização, download ou diagnóstico opcionais. + +### `iac-code.tool-progress.v1` + +Transporta progresso intermediário de ferramenta sem equivalente padrão. Início, argumentos e resultado final continuam como eventos padrão `TOOL_CALL_*` e não são duplicados aqui. + +### `iac-code.pipeline.v1` + +Somente informações úteis do Pipeline sem equivalente padrão completo são emitidas. Valores atuais de `eventType`: + +- Pipeline: `pipeline_started`, `pipeline_resumed`, `pipeline_completed`, `pipeline_error`, `pipeline_warning`, `backup_blocked`; +- candidatos: `candidate_started`, `candidate_completed`, `candidate_failed`, `candidate_interrupted`, `candidate_restart_requested`, `candidate_selected`, `candidate_detail_shown`, `candidate_step_failed`; +- sub-Pipelines e erros de etapa: `sub_pipeline_started`, `sub_pipeline_completed`, `sub_step_failed`, `step_failed`; +- stacks e limpeza: `stack_progress`, `stack_instances_progress`, `stack_current_changed`, `cleanup_started`, `cleanup_progress`, `cleanup_completed`, `cleanup_failed`; +- rollback: `rollback_triggered`, `rollback_completed`; +- contexto: `context_compaction_started`, `context_compacted`, `context_compaction_failed`, `fields_marked_stale`; +- apresentação e ferramentas: `diagram_shown`, `mcp_status`, `tool_progress`. + +Sinais com mapeamentos padrão não são duplicados como `CUSTOM`: `text_delta` se torna `TEXT_MESSAGE_*`, `thinking_delta` se torna `REASONING_*`, `tool_started/tool_result` se tornam `TOOL_CALL_*`, `usage` se torna `RUN_FINISHED.usage` e ciclos de etapas se tornam `STEP_*`. + +Clientes devem eliminar eventos de Pipeline repetidos usando `(name, value.eventId)` ou a sequência do Pipeline e tolerar eventos personalizados com namespace desconhecidos. + +## Interrupção + +Uma execução que requer entrada termina com `RUN_FINISHED.outcome.type = "interrupt"`. Cada interrupção contém: + +- `id` e `reason`; +- uma `message` para o usuário; +- um `toolCallId` opcional; +- um `responseSchema` JSON; +- `expiresAt`; +- metadados como `title`, `purpose`, `safeSummary`, `options` e `toolName`. + +Para uma solicitação de permissão, o esquema normalmente aceita: + +```json +{"decision": "allow_once"} +``` + +ou: + +```json +{"decision": "deny"} +``` + +Renderize `message`, `responseSchema` e os metadados descritivos em vez de inferir a interface apenas a partir de `reason`. Perguntas e seleção de opções podem usar esquemas diferentes. + +## Retomada + +Uma retomada é um novo `POST /` com o mesmo `threadId`, um novo `runId`, o mesmo `rosInvocationId` e uma entrada por interrupção pendente: + +```json +{ + "resume": [ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + } + ] +} +``` + +Regras: + +- cada interrupção pendente deve ser respondida exatamente uma vez; +- IDs duplicados e desconhecidos são rejeitados; +- `resolved` exige um payload compatível com o esquema; +- `cancelled` encerra a interrupção e corresponde a `deny` para permissões; +- o estado pendente durável só é removido depois que o A2A aceita a resposta; +- erros de esquema produzem `RUN_ERROR`, mantendo a interrupção disponível para nova tentativa; +- repetir uma resposta já aceita não executa a ferramenta novamente. + +Antes de aplicar uma retomada, o adaptador pode solicitar ao A2A que restaure a sessão do iac-code, verifica as identidades de tarefa e contexto A2A e recupera eventos de Pipeline ausentes. + +## Turnos e identidades + +```text +threadId (conversa estável) + ├─ runId-1 (turno do usuário) + ├─ runId-2 (retomada de interrupção) + ├─ runId-3 (outra retomada) + └─ runId-4 (próxima mensagem normal) +``` + +Cada solicitação HTTP/SSE usa um `runId` exclusivo. A retomada é uma nova execução. Após um turno normal, a próxima mensagem cria uma nova execução e reutiliza a sessão do iac-code do thread. A idempotência está no escopo de `(threadId, runId)`. + +## Extensão de cancelamento + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{"threadId": "thread-1", "rosInvocationId": "invocation-1"} +``` + +Os resultados possíveis são `cancelled`, `already_terminal` ou HTTP `404` com `EXECUTION_NOT_FOUND`. O cancelamento limpa interrupções pendentes e não altera os formatos padrão dos eventos AG-UI. + +## Persistência e recuperação + +O estado do adaptador usa por padrão: + +```text +/agui/threads/.json +``` + +Cada arquivo contém o vínculo entre thread, contexto e workspace, identidades de sessão, tarefa e execução, posições de recuperação do Pipeline, interrupções pendentes e dados de idempotência. O adaptador carrega sob demanda apenas o thread solicitado e substitui atomicamente somente o pequeno arquivo desse thread. + +Chaves de LLM, segredos AccessKey e tokens STS nunca são armazenados. Esse é um diretório de mapeamentos do adaptador, não de conversas ou artefatos de execução. O A2A gerencia sua própria persistência de sessões e tarefas; consulte a [documentação do A2A](../a2a/overview.md). + +Uma interrupção expirada é rejeitada no próximo acesso, seu estado pendente é removido e o adaptador tenta cancelar a tarefa A2A correspondente. + +## Desconexões + +- Uma execução concluída com segurança por uma interrupção deixa de depender da conexão SSE. +- Uma retomada cria uma nova conexão SSE. +- Desconectar uma execução normal ativa faz o adaptador cancelar a tarefa A2A. +- Desconectar após uma interrupção não apaga seu estado persistente de recuperação. + +## Erros + +Erros anteriores ao início do SSE usam um envelope JSON HTTP. Erros durante a execução usam eventos padrão `RUN_ERROR`. + +| Código | Significado | +|--------|-------------| +| `INVALID_INPUT` | Envelope, extensão, conteúdo de mensagem ou workspace inválido | +| `DUPLICATE_RUN_ID` | O mesmo digest de solicitação usou um run ID existente | +| `RUN_ID_CONFLICT` | Uma solicitação diferente reutilizou um run ID existente | +| `THREAD_BUSY` | O thread já tem uma execução ativa | +| `THREAD_BINDING_CONFLICT` | Workspace ou chamador conflita com o vínculo do thread | +| `RESUME_REQUIRED` | O thread aguarda respostas de interrupção | +| `INCOMPLETE_RESUME` | Interrupções pendentes ausentes ou IDs duplicados | +| `UNKNOWN_INTERRUPT` | A retomada referencia uma interrupção desconhecida | +| `RESUME_PAYLOAD_INVALID` | Payload ausente ou incompatível com o esquema | +| `RESUME_ALREADY_APPLIED` | A resposta já foi aplicada ou conflita com ela | +| `EXECUTION_EXPIRED` | A interrupção expirou | +| `EXECUTION_LOST` | Não foi possível recuperar o adaptador, a tarefa A2A ou a sessão do iac-code | +| `STATE_PERSISTENCE_FAILED` | O estado crítico para recuperação não pôde ser persistido | +| `A2A_UNAVAILABLE` | O serviço local de execução A2A está indisponível | +| `A2A_PROTOCOL_ERROR` | Identidade de tarefa, contexto ou sessão conflita com o mapeamento | +| `A2A_EXECUTION_FAILED` | A tarefa A2A terminou com falha | +| `CANCELLED` | A execução foi cancelada | + +Gravações críticas para recuperação falham de forma segura. O adaptador não anuncia uma tarefa, sessão ou interrupção recuperável antes que seu mapeamento esteja persistido, e cancela a tarefa A2A correspondente quando necessário. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json index 4f5819db..92204a73 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json @@ -27,6 +27,10 @@ "message": "A2A 协议", "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.AG-UI Protocol": { + "message": "AG-UI 协议", + "description": "The label for category 'AG-UI Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "自动化", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/getting-started.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/getting-started.md new file mode 100644 index 00000000..57bb4362 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/getting-started.md @@ -0,0 +1,276 @@ +--- +sidebar_position: 2 +title: 快速开始 +description: 安装、启动并调用 iac-code AG-UI adapter。 +--- + +# AG-UI 快速开始 + +## 前提条件 + +1. 已安装 Python 3.10 或更高版本。 +2. 已配置 iac-code 使用的 LLM provider。请参阅[认证](../configuration/authentication.md)。 +3. 如果任务需要访问 Alibaba Cloud,已配置云凭据,或由调用方按请求传入临时凭据。 +4. 已准备一个允许 iac-code 读写的工作区绝对路径。 + +安装 AG-UI 依赖: + +```bash +pip install "iac-code[agui]" +``` + +在源码仓库开发时使用: + +```bash +uv sync --extra agui +``` + +## 方式一:自动启动本地 A2A 内核 + +最简单的本地启动方式是省略 `--a2a-url`: + +```bash +iac-code agui --host 127.0.0.1 --port 41243 +``` + +AG-UI adapter 会自动选择空闲回环端口,启动受管的 `iac-code a2a` 子进程,并在退出时关闭它。子进程继承当前 iac-code 配置和运行环境。 + +这种方式适合本地开发和单进程管理。生产环境需要分别管理两个进程时,请使用下一种方式。 + +## 方式二:连接独立 A2A 内核 + +先启动 A2A server: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --thinking-exposure all +``` + +再启动 AG-UI adapter: + +```bash +iac-code agui \ + --host 0.0.0.0 \ + --port 41243 \ + --a2a-url http://127.0.0.1:41242 +``` + +两个服务的职责和端口相互独立:A2A 仍可继续对外提供 A2A 接口,AG-UI adapter 只通过回环地址调用它。 + +`--thinking-exposure all` 让 adapter 能把 raw thinking 转成标准 `REASONING_*` 事件。只有受信任的客户端才应启用 raw thinking;不需要展示推理时可以沿用 A2A 默认的 `tool-trace`。 + +如果 A2A server 启用了 Bearer token: + +```bash +export IACCODE_A2A_HTTP_TOKEN="a2a-local-secret" +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +AG-UI 进程需使用同一个 upstream token: + +```bash +export IAC_CODE_AGUI_A2A_TOKEN="a2a-local-secret" +iac-code agui --port 41243 --a2a-url http://127.0.0.1:41242 +``` + +## 使用 YAML 配置 + +静态启动参数可以写入 YAML: + +```yaml title="agui-server.yml" +host: 0.0.0.0 +port: 41243 +a2a-url: http://127.0.0.1:41242 +interrupt-ttl: 540 +state-dir: /var/lib/iac-code/agui +idle-shutdown: 0 +debug: false +log-stdout: true +``` + +启动: + +```bash +iac-code agui --config agui-server.yml +``` + +命令行显式参数优先于 YAML。token 等敏感值建议通过环境变量注入,而不是写入配置文件。 + +常用参数: + +| CLI / YAML | 默认值 | 含义 | +|------------|--------|------| +| `--host` / `host` | `127.0.0.1` | AG-UI HTTP 监听地址 | +| `--port` / `port` | `8000` | AG-UI HTTP 端口;部署示例使用 `41243` | +| `--a2a-url` / `a2a-url` | 空 | 本地 A2A URL;为空时启动受管子进程 | +| `--interrupt-ttl` / `interrupt-ttl` | `540` | Interrupt 可恢复秒数 | +| `--state-dir` / `state-dir` | `/agui` | AG-UI thread 状态目录 | +| `--idle-shutdown` / `idle-shutdown` | `0` | 空闲自动退出秒数;`0` 表示关闭 | +| `--debug` / `debug` | `false` | 调试日志 | +| `--log-stdout` / `log-stdout` | `false` | 同时向标准输出写日志 | + +相关环境变量: + +| 环境变量 | 用途 | +|------------|------| +| `IAC_CODE_AGUI_HOST` | AG-UI 监听地址 | +| `IAC_CODE_AGUI_PORT` | AG-UI 监听端口 | +| `IAC_CODE_AGUI_A2A_URL` | 本地 A2A upstream URL | +| `IAC_CODE_AGUI_A2A_TOKEN` | A2A upstream Bearer token | +| `IAC_CODE_AGUI_AUTH_TOKEN` | 保护 AG-UI endpoint 的 Bearer token | +| `IAC_CODE_AGUI_INTERRUPT_TTL` | Interrupt 有效期 | +| `IAC_CODE_AGUI_STATE_DIR` | AG-UI thread 状态目录 | +| `IAC_CODE_AGUI_ALLOWED_CWDS` | 允许的工作区根目录,使用操作系统路径分隔符分隔 | +| `IAC_CODE_CONFIG_DIR` | iac-code 配置根目录,也决定默认 AG-UI 状态目录 | + +## 健康检查 + +```bash +curl http://127.0.0.1:41243/health +``` + +响应示例: + +```json +{ + "status": "ok", + "protocol": "ag-ui", + "protocolPackageVersion": "0.1.20", + "executionKernel": "a2a-1.0", + "serverVersion": "当前 iac-code 版本" +} +``` + +## 使用官方 JavaScript client + +安装已验证的客户端版本: + +```bash +pnpm add @ag-ui/client@0.0.58 +``` + +下面的示例直接连接 `iac-code agui`。它使用标准 `HttpAgent`,并在 `forwardedProps` 中提供 iac-code 运行参数: + +```javascript +import { HttpAgent, randomUUID } from "@ag-ui/client"; + +const threadId = randomUUID(); +const rosInvocationId = randomUUID(); +const agent = new HttpAgent({ + url: "http://127.0.0.1:41243/", + threadId, + // 如果设置了 IAC_CODE_AGUI_AUTH_TOKEN: + // headers: { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` }, +}); + +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId, + cwd: process.cwd(), + runMode: "normal", + preferredLanguage: "zh-CN", + }, +}; + +agent.addMessage({ + id: randomUUID(), + role: "user", + content: "创建一个包含两个交换机的 VPC 模板。", +}); + +const subscriber = { + onTextMessageContentEvent({ event }) { + process.stdout.write(event.delta); + }, + onToolCallStartEvent({ event }) { + console.log(`\n[tool] ${event.toolCallName}`); + }, + onStepStartedEvent({ event }) { + console.log(`\n[step] ${event.stepName}`); + }, + onRunErrorEvent({ event }) { + console.error(`\n${event.code}: ${event.message}`); + }, +}; + +await agent.runAgent({ forwardedProps }, subscriber); +``` + +如果 endpoint 设置了 Bearer token,使用 `HttpAgent.headers` 传入 `Authorization`。 + +浏览器页面通常需要通过同源后端或反向代理连接 AG-UI endpoint;当前 adapter 不负责添加跨域策略。 + +## 处理 Interrupt + +官方 client 会把 `RUN_FINISHED.outcome.interrupts` 维护在 `agent.pendingInterrupts` 中。必须根据每个 Interrupt 的 `responseSchema` 构造响应,并在新 run 中提交: + +```javascript +const responses = agent.pendingInterrupts.map((interrupt) => ({ + interruptId: interrupt.id, + status: "resolved", + payload: { decision: "allow_once" }, +})); + +await agent.runAgent( + { + forwardedProps, + resume: responses, + }, + subscriber, +); +``` + +上例只适用于 `responseSchema` 要求 `decision` 的权限 Interrupt。提问和方案选择必须按各自 schema 提交,不要假设所有 Interrupt 都有相同结构。 + +Resume 必须满足: + +- 使用原来的 `threadId`; +- 使用新的 `runId`(官方 client 默认生成); +- `rosInvocationId` 与被中断的 execution 保持一致; +- 一次覆盖当前所有 pending Interrupt; +- `status` 为 `resolved` 时提供符合 `responseSchema` 的 `payload`; +- 不想继续时可发送 `status: "cancelled"`。 + +## 启动 Pipeline + +将 `runMode` 改为 `pipeline`,并按需指定 Pipeline 名称: + +```javascript +const forwardedProps = { + iacCode: { + schemaVersion: 1, + rosInvocationId: randomUUID(), + cwd: process.cwd(), + runMode: "pipeline", + pipelineName: "selling", + candidatePresentation: "rich", + }, +}; +``` + +客户端应同时处理 `STEP_*`、`TOOL_CALL_*`、`ACTIVITY_SNAPSHOT` 和 `CUSTOM`。不认识 iac-code 自定义事件的通用客户端仍可正常处理标准事件。 + +## 工作区与临时凭据 + +`cwd` 不在服务启动时固定,而是每个请求都必须提供。它必须是绝对路径,并位于 `IAC_CODE_AGUI_ALLOWED_CWDS` 或 `IACCODE_A2A_ALLOWED_CWDS` 允许的根目录内。 + +调用方可以通过 `forwardedProps.iacCode` 传入单次请求的模型、LLM key 和 Alibaba Cloud 临时凭据。AG-UI adapter 不把这些 secret 写入自己的 thread 状态文件;它们会被转发给 A2A 执行内核,并按 A2A 的请求级覆盖规则使用。 + +## 持久化目录 + +默认状态结构为: + +```text +/agui/ + threads/ + .json +``` + +每个 thread 独立写入,不会在进程启动时扫描全部历史 thread。普通 UUID 保持可读文件名;无法安全作为文件名的 ID 会被编码,超长 ID 使用固定长度文件键,JSON 内容始终保存并校验原始 `threadId`。 + +该目录只保存 AG-UI adapter 的 thread 映射、Interrupt 和幂等状态,不保存对话正文或请求中的凭据。不要手工编辑其中的 JSON 文件。 + +## 下一步 + +- [AG-UI 协议概览](./overview.md) +- [协议参考](./protocol-reference.md) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/overview.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/overview.md new file mode 100644 index 00000000..71c96a39 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/overview.md @@ -0,0 +1,135 @@ +--- +sidebar_position: 1 +title: AG-UI 协议 +description: iac-code AG-UI 接入的架构、能力和适用场景。 +--- + +# AG-UI 协议 + +## 什么是 AG-UI + +[Agent-User Interaction Protocol(AG-UI)](https://docs.ag-ui.com/concepts/architecture) 是 agent 与用户侧应用之间的事件流协议。客户端通过 `RunAgentInput` 发起一次运行,并通过 HTTP Server-Sent Events(SSE)接收文本、推理、工具调用、步骤、状态和 Interrupt 等结构化事件。 + +AG-UI 适合 Web 控制台、聊天客户端、IDE 插件和其他需要实时展示 agent 执行过程的应用。与只消费最终文本相比,AG-UI 客户端可以分别渲染模型回答、工具参数、工具结果、Pipeline 步骤和待用户确认的操作。 + +## iac-code 的实现架构 + +iac-code 使用 **A2A 执行内核 + AG-UI 协议适配器**: + +```text +AG-UI client + ↓ RunAgentInput / SSE +iac-code agui + ↓ A2A 1.0 HTTP +iac-code a2a + ↓ +Agent loop / Pipeline / LLM / Alibaba Cloud API +``` + +`iac-code a2a` 是唯一执行内核,负责: + +- normal 会话和 Pipeline 执行; +- iac-code session、A2A context 和 task; +- 工具权限、提问、方案选择和恢复; +- 执行生命周期和取消; +- LLM 与 Alibaba Cloud API 调用。 + +`iac-code agui` 不创建第二套 Agent runtime,也不直接运行 Pipeline。它只负责: + +- 将 AG-UI `RunAgentInput` 转换为 A2A 请求; +- 将 A2A 事件投影为标准 AG-UI 事件; +- 映射 `threadId/runId` 与 A2A `contextId/taskId`; +- 将 AG-UI `resume[]` 转换为 A2A 输入恢复; +- 持久化协议映射和待处理 Interrupt; +- 将取消请求转发给 A2A。 + +因此,AG-UI 与 A2A 不会各自维护一套执行语义。模型选择、云凭据、权限规则和 Pipeline 行为最终都由同一个 A2A runtime 处理。 + +## 标准协议与 iac-code 扩展 + +对外事件流使用标准 AG-UI 事件,包括: + +- `RUN_STARTED`、`RUN_FINISHED` 和 `RUN_ERROR`; +- `TEXT_MESSAGE_*`; +- `REASONING_*`; +- `TOOL_CALL_*`; +- `STEP_STARTED` 和 `STEP_FINISHED`; +- `ACTIVITY_SNAPSHOT`。 + +只有标准事件无法表达、且客户端确实需要展示的 iac-code Pipeline 信息才使用命名空间明确的 `CUSTOM` 事件。普通 AG-UI 客户端可以忽略这些 `CUSTOM` 事件,不影响文本、工具调用、Interrupt 和 run 生命周期。 + +请求仍是标准 `RunAgentInput`。iac-code 通过标准的 `forwardedProps` 承载工作区、运行模式和临时凭据等服务端必需信息: + +```json +{ + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "request-identity", + "cwd": "/absolute/workspace/path", + "runMode": "normal" + } + } +} +``` + +这意味着通用 AG-UI client 可以直接消费 iac-code 的标准事件,但直接调用 `iac-code agui` 时,仍需在 `forwardedProps.iacCode` 中提供 `cwd` 等运行参数。 + +## 支持的交互 + +### 多轮 normal 会话 + +同一会话持续使用相同 `threadId`,每个用户 turn 使用新的 `runId`。AG-UI adapter 将其绑定到同一个 iac-code session;一个 turn 完成后,下一条用户消息通过新的 HTTP/SSE 请求开始,不会继续使用上一个已经结束的 SSE 响应。 + +### Pipeline + +设置 `forwardedProps.iacCode.runMode` 为 `pipeline` 后,执行仍由 A2A Pipeline 内核完成。顶层步骤映射为标准 `STEP_*`,agent 文本、推理和工具调用映射为对应的标准事件;没有标准等价物的候选方案、资源栈进度和清理进度通过 `iac-code.pipeline.v1` 自定义事件输出。 + +Pipeline 中的并行 sub-pipeline 使用不同的消息和步骤标识,不会把多个 agent loop 的文本合并成同一条消息。 + +### Interrupt 与 Resume + +当权限申请、提问或方案选择需要用户输入时,当前 run 会以以下事件结束: + +```json +{ + "type": "RUN_FINISHED", + "outcome": { + "type": "interrupt", + "interrupts": [] + } +} +``` + +Interrupt 已持久化后才会向客户端发布。客户端收集响应,再以同一 `threadId`、新的 `runId` 和 `resume[]` 发起新请求。Resume 的 SSE 连接属于这个新请求,不会重新接回旧连接。 + +### Adapter 状态 + +AG-UI adapter 按 thread 分文件保存协议映射、幂等信息和待处理 Interrupt。该目录不保存对话正文、LLM key 或云凭据,也不是会话导出目录。 + +## 何时使用 AG-UI + +| 需求 | 推荐模式 | +|------|----------| +| 构建聊天界面并实时展示文本、推理、工具和步骤 | **AG-UI** | +| 在 UI 中处理权限、提问和方案选择 | **AG-UI** | +| 另一个 agent 或编排系统直接调用 iac-code | **A2A** | +| IDE/编辑器使用 ACP session 和终端能力 | **ACP** | +| 本地人工操作 | **交互式 REPL 或 Web/Desktop** | + +AG-UI 和 A2A 可以同时启动。对外提供两种协议时,它们仍共享 iac-code 的执行实现,而不是共享同一个 HTTP 端点。 + +## 当前边界 + +- AG-UI 传输为 HTTP POST + SSE。 +- A2A upstream 必须是本机回环地址;AG-UI adapter 不允许连接任意远程 A2A URL。 +- `cwd` 必须按请求传入,并且必须位于允许的工作区根目录内。 +- 当前不接受 client 自定义的 `tools`;工具集合由 iac-code runtime 管理。 +- 用户消息支持文本和内联 base64 图片;不接收远程媒体 URL。 +- 客户端主动断开一个仍在运行、尚未进入 Interrupt 的 SSE 时,adapter 会取消对应 A2A task。 +- SSE 每 15 秒发送注释形式的 heartbeat;符合规范的 SSE 客户端会忽略它。 + +## 后续阅读 + +- [快速开始](./getting-started.md) — 安装、启动和连接第一个客户端。 +- [协议参考](./protocol-reference.md) — 请求字段、事件、Interrupt/Resume、持久化和错误语义。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md new file mode 100644 index 00000000..a1c4ce9c --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/agui/protocol-reference.md @@ -0,0 +1,414 @@ +--- +sidebar_position: 3 +title: 协议参考 +description: iac-code AG-UI 请求、事件、Interrupt、恢复、取消和持久化参考。 +--- + +# AG-UI 协议参考 + +本文描述 `iac-code agui` 暴露的 HTTP/SSE 接口,以及 iac-code 在标准 AG-UI envelope 中使用的扩展字段。架构和启动方式分别参见[协议概览](./overview.md)与[快速开始](./getting-started.md)。 + +## HTTP 接口 + +| 方法与路径 | 用途 | +|------------|------| +| `GET /health` | 健康检查和协议版本信息 | +| `POST /` | 提交 AG-UI `RunAgentInput`,响应为 SSE 事件流 | +| `POST /extensions/iac-code/v1/executions/{executionId}/cancel` | 取消 iac-code execution 的命名空间扩展 | + +`POST /` 的请求体必须使用 JSON,客户端应声明接收 SSE: + +```http +Content-Type: application/json +Accept: text/event-stream +``` + +如果配置了 `IAC_CODE_AGUI_AUTH_TOKEN`,所有受保护请求还必须携带: + +```http +Authorization: Bearer +``` + +可使用标准 `Accept-Language` 请求头作为错误消息语言的兜底。`forwardedProps.iacCode.preferredLanguage` 的优先级更高,并会继续传给 A2A runtime。 + +## RunAgentInput + +最小 normal 请求示例: + +```json +{ + "threadId": "8473547e-c8ed-4aef-a84c-603a6a8d42da", + "runId": "32c263f2-b0b0-42ac-905c-524a0a9bb652", + "state": {}, + "messages": [ + { + "id": "message-1", + "role": "user", + "content": "创建一个 VPC 模板" + } + ], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1", + "runMode": "normal" + } + } +} +``` + +### 标准字段 + +| 字段 | 要求 | iac-code 行为 | +|------|------|---------------| +| `threadId` | 必填、非空字符串 | 会话稳定标识;映射到一个 A2A context 和 iac-code session | +| `runId` | 必填、非空字符串 | 单次 HTTP/SSE run 标识;同一 thread 内不能复用 | +| `parentRunId` | 可选 | 原样用于 `RUN_STARTED` | +| `state` | 必填 | 保留在标准 envelope 中;不作为 iac-code runtime 状态源 | +| `messages` | 必填 | 新 run 使用最新一条 user message;Resume 可以不新增 user message | +| `tools` | 必填且必须为空数组 | client 自定义工具当前不受支持 | +| `context` | 必填 | 保留在标准 envelope 中;当前不转换为 iac-code prompt context | +| `forwardedProps` | 必填 | 必须包含 `iacCode` 扩展 | +| `resume` | Resume 时使用 | 对上一个 Interrupt run 的逐项响应 | + +用户消息支持: + +- 字符串文本; +- `type: "text"` 的文本 part; +- `type: "image"` 且 `source.type: "data"` 的内联 base64 图片。 + +不支持远程图片 URL、音频、视频、document 或通用 binary part。单张解码后图片不得超过 8 MiB,全部图片合计不得超过 10 MiB;整个 HTTP 请求体上限为 12 MiB。 + +## `forwardedProps.iacCode` + +该对象使用严格 schema,未知字段会被拒绝。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `schemaVersion` | `1` | 是 | iac-code 扩展版本 | +| `rosInvocationId` | string | 是 | 当前 execution 的调用标识,最长 256 字符 | +| `cwd` | string | 是 | 本次执行的绝对工作区路径 | +| `model` | string | 否 | 单次请求覆盖模型 | +| `llmApiKey` | string | 否 | 单次请求覆盖 LLM provider key | +| `thinking.enabled` | boolean | 否 | 是否请求 thinking | +| `thinking.effort` | string | 否 | provider 支持时覆盖 thinking effort | +| `thinking.budget` | positive integer | 否 | provider 支持时覆盖 thinking budget | +| `userId` | string | 否 | telemetry 和调用方绑定标识 | +| `channel` | string | 否 | 调用渠道元数据 | +| `preferredLanguage` | string | 否 | 请求级用户可见语言,例如 `zh-CN` | +| `candidatePresentation` | `standard` 或 `rich` | 否 | Pipeline 候选方案呈现方式 | +| `runMode` | `normal` 或 `pipeline` | 否 | 运行模式;缺省由 A2A runtime 决定 | +| `pipelineName` | string | 否 | Pipeline 名称,例如 `selling` | +| `cleanupOnly` | boolean | 否 | 请求 Pipeline 只执行清理路径 | +| `alibabaCloud.accessKeyId` | string | 否 | 请求级 Alibaba Cloud AccessKey ID | +| `alibabaCloud.accessKeySecret` | string | 否 | 请求级 Alibaba Cloud AccessKey Secret | +| `alibabaCloud.securityToken` | string | 否 | 请求级 STS token | +| `alibabaCloud.regionId` | string | 否 | 请求级默认 region | + +`rosInvocationId` 的生命周期: + +- initial run 与其 Interrupt Resume 必须使用相同值; +- normal turn 成功结束后,下一 turn 可以使用新值; +- Cancel 请求也必须提供当前 execution 的值。 + +同一 `threadId` 会绑定到首次请求的 `cwd` 和 `userId`。后续请求不能用同一 thread 切换到另一个工作区或调用方。 + +## SSE 格式与 heartbeat + +每个 AG-UI 事件以 SSE `data:` 行发送: + +```text +data: {"type":"RUN_STARTED",...} + +data: {"type":"TEXT_MESSAGE_START",...} + +data: {"type":"TEXT_MESSAGE_CONTENT",...} +``` + +当 15 秒内没有事件时,服务器发送 SSE 注释: + +```text +: heartbeat +``` + +它不是 AG-UI `CUSTOM` 事件,不进入客户端事件模型。标准 SSE 实现应忽略注释,同时用它保持 HTTP 连接活跃。 + +## 标准事件映射 + +| A2A/iac-code 信号 | AG-UI 输出 | +|-------------------|------------| +| 新请求被接受 | `RUN_STARTED` | +| agent 文本 | `TEXT_MESSAGE_START/CONTENT/END` | +| raw thinking | `REASONING_START`、`REASONING_MESSAGE_*`、`REASONING_END` | +| 工具开始与参数 | `TOOL_CALL_START/ARGS/END` | +| 工具结果 | `TOOL_CALL_RESULT` | +| Pipeline step 生命周期 | `STEP_STARTED/STEP_FINISHED` | +| Pipeline 恢复快照 | `ACTIVITY_SNAPSHOT` | +| 正常完成 | `RUN_FINISHED`,`outcome.type = "success"` | +| 等待用户输入 | `RUN_FINISHED`,`outcome.type = "interrupt"` | +| adapter 或 A2A 错误 | `RUN_ERROR` | + +`RUN_FINISHED` 表示一个 AG-UI run 的结束,不等同于整个 Pipeline 的结束。一次 Pipeline 可能因为多个 Interrupt 产生多个 run,每个 run 都有自己的 `RUN_STARTED` 和 `RUN_FINISHED`。Pipeline 业务终态由 `pipeline_completed` 或 `pipeline_error` 等 Pipeline 事件表达。 + +为满足 AG-UI 的 span 平衡约束,Interrupt 结束当前 run 前会关闭打开的 message、reasoning、tool 和 step span。Resume 的新 run 会重新打开仍处于活动状态的持久 Pipeline step。因此跨 Interrupt 查看原始事件时,可能看到同一业务 step 在不同 run 中分别关闭和重新开始;这不是 Pipeline 倒序执行。 + +## iac-code 自定义事件 + +### `iac-code.session.v1` + +该事件暴露 adapter 与 A2A 的当前映射: + +```json +{ + "type": "CUSTOM", + "name": "iac-code.session.v1", + "value": { + "schemaVersion": 1, + "threadId": "...", + "aguiRunId": "...", + "executionId": "...", + "contextId": "...", + "taskId": "...", + "rosInvocationId": "...", + "sessionId": "..." + } +} +``` + +`executionId` 可用于取消扩展。普通客户端可以安全忽略这个映射事件。 + +### `iac-code.artifact.v1` + +承载 A2A task artifact 的结构化投影。客户端可以按需提供下载、预览或调试展示。 + +### `iac-code.tool-progress.v1` + +承载没有标准 AG-UI 等价物的工具中间进度。工具开始、参数和最终结果仍使用标准 `TOOL_CALL_*`,不会在该事件中重复发送。 + +### `iac-code.pipeline.v1` + +只发送对 UI 有价值且没有完整标准等价物的 Pipeline 信息。当前允许的 `eventType` 包括: + +- Pipeline:`pipeline_started`、`pipeline_resumed`、`pipeline_completed`、`pipeline_error`、`pipeline_warning`、`backup_blocked`; +- 候选方案:`candidate_started`、`candidate_completed`、`candidate_failed`、`candidate_interrupted`、`candidate_restart_requested`、`candidate_selected`、`candidate_detail_shown`、`candidate_step_failed`; +- sub-pipeline 与步骤错误:`sub_pipeline_started`、`sub_pipeline_completed`、`sub_step_failed`、`step_failed`; +- 资源栈与清理:`stack_progress`、`stack_instances_progress`、`stack_current_changed`、`cleanup_started`、`cleanup_progress`、`cleanup_completed`、`cleanup_failed`; +- 回滚:`rollback_triggered`、`rollback_completed`; +- 上下文:`context_compaction_started`、`context_compacted`、`context_compaction_failed`、`fields_marked_stale`; +- 展示与工具:`diagram_shown`、`mcp_status`、`tool_progress`。 + +以下 A2A Pipeline 信号已有标准映射,因此不会再作为 `CUSTOM` 重复发送: + +- `text_delta` → `TEXT_MESSAGE_*`; +- `thinking_delta` → `REASONING_*`; +- `tool_started` / `tool_result` → `TOOL_CALL_*`; +- `usage` → `RUN_FINISHED.usage`; +- step lifecycle → `STEP_*`。 + +客户端应按 `(name, value.eventId)` 或 Pipeline sequence 处理可能的重放,并允许忽略未知的 namespaced 自定义事件。 + +## Interrupt + +等待输入时,一个 run 以 `RUN_FINISHED` 结束: + +```json +{ + "type": "RUN_FINISHED", + "threadId": "thread-1", + "runId": "run-1", + "outcome": { + "type": "interrupt", + "interrupts": [ + { + "id": "permission-1", + "reason": "tool_call", + "message": "Create a cloud resource. Allow once?", + "toolCallId": "call-1", + "responseSchema": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["allow_once", "deny"] + } + }, + "required": ["decision"], + "additionalProperties": false + }, + "expiresAt": "2026-08-27T03:00:00Z", + "metadata": { + "schemaVersion": 1, + "kind": "permission", + "toolName": "ros_stack", + "title": "Create a cloud resource", + "purpose": "Deploy the approved resource stack", + "safeSummary": "ros_stack: create stack" + } + } + ] + } +} +``` + +客户端渲染时应优先使用: + +- `message`:面向用户的完整问题; +- `responseSchema`:合法响应结构; +- `metadata.title/purpose/safeSummary`:通用说明和安全摘要; +- `metadata.options`:可用选择; +- `toolCallId`:关联之前的标准工具调用事件。 + +不要只根据 `reason` 推断 UI。权限、自由文本提问和方案选择可能使用不同 schema。 + +## Resume + +Resume 是一个新的 `POST /`,使用同一 `threadId` 和新 `runId`: + +```json +{ + "threadId": "thread-1", + "runId": "run-2", + "state": {}, + "messages": [], + "tools": [], + "context": [], + "forwardedProps": { + "iacCode": { + "schemaVersion": 1, + "rosInvocationId": "invocation-1", + "cwd": "/workspace/session-1" + } + }, + "resume": [ + { + "interruptId": "permission-1", + "status": "resolved", + "payload": {"decision": "allow_once"} + } + ] +} +``` + +规则: + +- 当前所有 pending Interrupt 必须恰好响应一次; +- 不允许重复或未知的 `interruptId`; +- `resolved` 必须携带符合对应 `responseSchema` 的 `payload`; +- `cancelled` 表示不继续该 Interrupt;权限 Interrupt 会按 `deny` 处理; +- 响应在 A2A 接受后才会从 durable pending 状态中移除; +- schema 校验失败时返回 `RUN_ERROR`,原 Interrupt 保持可重试; +- 重复提交同一已接受响应会返回幂等相关错误,而不会重复执行工具。 + +在 Resume 前,adapter 会按需请求 A2A 恢复 iac-code session,然后校验 A2A task/context 身份,并恢复遗漏的 Pipeline 增量事件。 + +## 多 turn 与标识 + +```text +threadId(稳定会话) + ├─ runId-1(用户 turn) + ├─ runId-2(Interrupt Resume) + ├─ runId-3(下一次 Resume) + └─ runId-4(会话下一条普通消息) +``` + +- `threadId` 在整个会话中稳定。 +- 每次 HTTP/SSE 请求使用唯一 `runId`。 +- Interrupt Resume 仍是一个新 run。 +- 一个普通 turn 成功完成后,下一条用户消息创建新的 execution,但继续复用 thread 对应的 iac-code session。 +- `runId` 幂等范围是 `(threadId, runId)`;已使用的 run ID 不能再次用于新请求。 + +## 取消扩展 + +取消请求: + +```http +POST /extensions/iac-code/v1/executions//cancel +Content-Type: application/json +``` + +```json +{ + "threadId": "thread-1", + "rosInvocationId": "invocation-1" +} +``` + +`executionId` 来自 `iac-code.session.v1`。响应状态可能是: + +| `status` | 含义 | +|----------|------| +| `cancelled` | 已请求 A2A 取消,并把 execution 标记为终态 | +| `already_terminal` | execution 已结束 | +| HTTP `404` / `EXECUTION_NOT_FOUND` | execution 不存在或身份不匹配 | + +取消会清除 pending Interrupt。该扩展不改变标准 AG-UI run 事件格式。 + +## 持久化与恢复 + +AG-UI 状态默认保存在: + +```text +/agui/threads/.json +``` + +单个文件保存: + +- `threadId`、`contextId`、`cwd` 和 `userId` 绑定; +- iac-code session ID; +- 当前 `executionId`、`rosInvocationId` 和 A2A `taskId`; +- Pipeline sequence、打开的步骤和文本快照摘要; +- pending Interrupt 与有效期; +- run、Resume 和终态幂等信息。 + +adapter 启动时不扫描全部目录。收到 thread 请求后才懒加载对应文件,每次只原子替换当前 thread 的小文件。 + +AG-UI 状态不保存 LLM key、AccessKey secret 或 STS token。该目录只属于协议 adapter,不是 iac-code 对话正文或执行产物的存储位置。A2A 的会话和任务持久化由 A2A server 自己管理,详见 [A2A 文档](../a2a/overview.md)。 + +如果 Interrupt 超过 `expiresAt`,下一次访问会拒绝 Resume、清理 pending,并尽力取消对应 A2A task。 + +## 断开连接 + +- 已经以 Interrupt 安全结束的 run 不占用原 SSE 连接。 +- Resume 到来时会创建新的 SSE 连接。 +- 客户端在普通活动 run 中主动断开时,adapter 会取消对应 A2A task,避免后台无限运行。 +- 对于已持久化的 pending Interrupt,断开不会删除其恢复状态。 + +## 错误响应 + +在 SSE 建立前发生的错误使用 HTTP JSON: + +```json +{ + "error": { + "code": "INVALID_INPUT", + "message": "Invalid AG-UI RunAgentInput envelope." + } +} +``` + +运行期间错误使用标准 `RUN_ERROR`。常见 code: + +| code | 含义 | +|------|------| +| `INVALID_INPUT` | envelope、扩展字段、消息内容或工作区无效 | +| `DUPLICATE_RUN_ID` | 相同请求摘要使用了已存在的 run ID | +| `RUN_ID_CONFLICT` | 不同请求复用了已存在的 run ID | +| `THREAD_BUSY` | 同一 thread 已有活动 run | +| `THREAD_BINDING_CONFLICT` | thread 的 cwd 或 userId 与已有绑定冲突 | +| `RESUME_REQUIRED` | thread 正在等待 Interrupt 响应 | +| `INCOMPLETE_RESUME` | 未覆盖全部 pending Interrupt,或包含重复 ID | +| `UNKNOWN_INTERRUPT` | Resume 引用了未知 Interrupt | +| `RESUME_PAYLOAD_INVALID` | payload 缺失或不符合 response schema | +| `RESUME_ALREADY_APPLIED` | 响应已应用,或重复请求与已应用内容冲突 | +| `EXECUTION_EXPIRED` | Interrupt 已过期 | +| `EXECUTION_LOST` | adapter、A2A task 或 iac-code session 无法恢复 | +| `STATE_PERSISTENCE_FAILED` | 恢复关键状态无法可靠写入 | +| `A2A_UNAVAILABLE` | 本地 A2A execution service 不可用 | +| `A2A_PROTOCOL_ERROR` | A2A task/context/session 身份不符合既有映射 | +| `A2A_EXECUTION_FAILED` | A2A task 以失败状态结束 | +| `CANCELLED` | execution 已取消 | + +adapter 对需要可靠恢复的写入采用 fail closed:如果 task、session 或 Interrupt 映射不能持久化,它不会先向客户端宣告可恢复成功,必要时会取消对应 A2A task。 diff --git a/website/sidebars.ts b/website/sidebars.ts index 1309d5a4..6fe6c6f2 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -82,6 +82,15 @@ const sidebars: SidebarsConfig = { 'a2a/examples', ], }, + { + type: 'category', + label: 'AG-UI Protocol', + items: [ + 'agui/overview', + 'agui/getting-started', + 'agui/protocol-reference', + ], + }, ], }, 'contact',