From 4fb8e0cdc477bf32fb72b8f4bc3f2d9b879f9cf2 Mon Sep 17 00:00:00 2001 From: rzx Date: Wed, 1 Jul 2026 09:57:50 +0800 Subject: [PATCH] Add A2A thinking metadata controls --- .gitignore | 1 + scripts/README.zh-CN.md | 48 +++++ scripts/a2a/debugger.md | 11 +- scripts/a2a/debugger.py | 172 ++++++++++++++---- scripts/a2a/debugger.zh-CN.md | 105 +++++++++++ .../a2a/selling_console_web/README.zh-CN.md | 36 ++++ scripts/observability/local_observe.zh-CN.md | 38 ++++ src/iac_code/a2a/client.py | 28 ++- src/iac_code/a2a/executor.py | 10 + src/iac_code/a2a/pipeline_events.py | 15 ++ src/iac_code/a2a/pipeline_executor.py | 29 ++- src/iac_code/a2a/pipeline_stream.py | 35 +--- src/iac_code/a2a/runtime_overrides.py | 25 ++- src/iac_code/a2a/thinking_metadata.py | 59 ++++++ src/iac_code/agent/agent_loop.py | 5 + src/iac_code/cli/main.py | 30 +++ src/iac_code/providers/anthropic_provider.py | 53 +++++- .../providers/azure_openai_provider.py | 8 +- src/iac_code/providers/dashscope_provider.py | 4 + src/iac_code/providers/deepseek_provider.py | 6 + src/iac_code/providers/gemini_provider.py | 14 +- src/iac_code/providers/kimi_provider.py | 8 + src/iac_code/providers/lmstudio_provider.py | 8 +- src/iac_code/providers/manager.py | 117 ++++++++++-- src/iac_code/providers/minimax_provider.py | 6 + src/iac_code/providers/modelscope_provider.py | 8 +- src/iac_code/providers/ollama_provider.py | 8 +- src/iac_code/providers/openai_provider.py | 92 ++++++++-- src/iac_code/providers/openrouter_provider.py | 6 + src/iac_code/providers/request_logging.py | 97 ++++++++++ src/iac_code/providers/request_policy.py | 63 +++++++ .../providers/siliconflow_provider.py | 8 +- src/iac_code/providers/volcengine_provider.py | 8 +- src/iac_code/providers/zhipu_provider.py | 8 + src/iac_code/services/permissions/audit.py | 33 ++-- src/iac_code/ui/repl.py | 7 + tests/a2a/test_client.py | 38 ++++ tests/a2a/test_executor.py | 59 ++++++ tests/a2a/test_permission_audit.py | 3 + tests/a2a/test_pipeline_debugger_script.py | 114 +++++++++++- tests/a2a/test_pipeline_events.py | 48 +++++ tests/a2a/test_pipeline_executor.py | 130 ++++++++++++- tests/a2a/test_pipeline_stream.py | 35 +++- tests/a2a/test_selling_console_script.py | 6 +- tests/a2a/test_thinking_metadata.py | 22 +++ .../test_permission_audit_integration.py | 11 +- tests/cli/test_a2a_command.py | 29 +++ tests/mcp/test_oauth.py | 25 ++- tests/providers/test_anthropic_provider.py | 28 +++ tests/providers/test_dashscope_provider.py | 23 +++ tests/providers/test_manager.py | 72 ++++++++ tests/providers/test_openai_provider.py | 15 ++ .../test_provider_model_research_updates.py | 29 +++ tests/providers/test_request_logging.py | 94 ++++++++++ tests/services/permissions/test_audit.py | 107 ++++++++--- tests/ui/test_repl_shell_escape.py | 5 + website/docs/a2a/command-reference.md | 11 ++ website/docs/a2a/getting-started.md | 4 + .../configuration/runtime-configuration.md | 5 + .../current.json | 4 + .../current/a2a/command-reference.md | 15 ++ .../current/a2a/getting-started.md | 6 + .../configuration/runtime-configuration.md | 5 + .../current.json | 4 + .../current/a2a/command-reference.md | 15 ++ .../current/a2a/getting-started.md | 6 + .../configuration/runtime-configuration.md | 5 + .../current.json | 4 + .../current/a2a/command-reference.md | 15 ++ .../current/a2a/getting-started.md | 6 + .../configuration/runtime-configuration.md | 5 + .../current.json | 4 + .../current/a2a/command-reference.md | 15 ++ .../current/a2a/getting-started.md | 6 + .../configuration/runtime-configuration.md | 5 + .../current.json | 4 + .../current/a2a/command-reference.md | 15 ++ .../current/a2a/getting-started.md | 6 + .../configuration/runtime-configuration.md | 5 + .../current/a2a/command-reference.md | 11 ++ .../current/a2a/getting-started.md | 4 + .../configuration/runtime-configuration.md | 5 + 82 files changed, 2092 insertions(+), 200 deletions(-) create mode 100644 scripts/README.zh-CN.md create mode 100644 scripts/a2a/debugger.zh-CN.md create mode 100644 scripts/a2a/selling_console_web/README.zh-CN.md create mode 100644 scripts/observability/local_observe.zh-CN.md create mode 100644 src/iac_code/a2a/thinking_metadata.py create mode 100644 src/iac_code/providers/request_logging.py create mode 100644 src/iac_code/providers/request_policy.py create mode 100644 tests/a2a/test_thinking_metadata.py create mode 100644 tests/providers/test_request_logging.py diff --git a/.gitignore b/.gitignore index 6c6b200d..a10b16d8 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ cython_debug/ # Super Powers .superpowers/ .worktrees/ +.agents/ \ No newline at end of file diff --git a/scripts/README.zh-CN.md b/scripts/README.zh-CN.md new file mode 100644 index 00000000..f4badc3d --- /dev/null +++ b/scripts/README.zh-CN.md @@ -0,0 +1,48 @@ +# Scripts + +本目录包含本地开发、手工测试和端到端辅助脚本。除非某个脚本自己的 README 另有说明, +否则请从仓库根目录使用 `uv run python ...` 运行脚本。 + +## 目录结构 + +| 路径 | 用途 | +| --- | --- | +| `a2a/debugger.py` | A2A pipeline stream 的 Web debugger / client。 | +| `a2a/debugger.md` | A2A debugger 的手工使用说明。 | +| `a2a/selling_console.py` | Selling pipeline console 的本地 HTTP server;把纯文本 UI 请求代理到 A2A server。 | +| `a2a/selling_console_web/` | 静态 Selling Console 前端。它负责渲染 pipeline 进度、候选方案卡片、聊天和调试面板;图片输入覆盖范围属于 `a2a/debugger.py`。 | +| `a2a/e2e/` | A2A 会话恢复端到端场景 runner、共享 helper 和结果说明。 | +| `a2a/smoke/test_a2a_vpc.py` | A2A VPC / pipeline 行为的小型手工 smoke 脚本。 | +| `acp/smoke/test_acp_vpc.py` | ACP VPC 行为的小型手工 smoke 脚本。 | +| `headless/smoke/test_headless_vpc.py` | Headless VPC 行为的小型手工 smoke 脚本。 | +| `observability/local_observe.py` | 本地 OTLP observability server 入口。 | +| `observability/local_observe/` | 本地 observe server 实现和静态 Web UI。 | +| `observability/local_observe.md` | 本地 observe 工具的手工使用说明。 | +| `rendering/test_diagram_render.py` | 手工图表渲染检查。 | +| `repl/e2e/` | 基于真实 PTY 驱动的 REPL pipeline 端到端场景 runner。因为使用 `pexpect`,仅支持 POSIX 环境。 | + +## 常用命令 + +```bash +uv run python scripts/a2a/debugger.py --help +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/selling_console.py --port 41980 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "$PWD" +uv run python scripts/a2a/e2e/run_recovery_scenarios.py --help +uv run python scripts/observability/local_observe.py --help +uv run python scripts/repl/e2e/run_pipeline_scenarios.py --help +``` + +`scripts/repl/e2e/run_pipeline_scenarios.py` 默认把产物写到系统临时目录,主要用于手工验证 +或 smoke 验证。它不是 `make test` 的一部分;单元测试只覆盖 helper 行为。真实 PTY runner +依赖仅支持 POSIX 的开发依赖 `pexpect`。 + +根目录的 `conftest.py` 包含 tiktoken 隔离 fixture,确保测试不会读写开发者真实的 encoding +cache。新增测试应继续走这条 fixture 路径,不要直接使用用户缓存。 + +Cleanup ledger 的临时文件名使用前导点只是外观约定。正确性依赖 atomic replace、重试和 ledger +校验,而不是 Unix hidden-file 行为。 + +这些 helper 的 pytest 测试位于 `tests/` 下;本目录里的可执行脚本保留给本地调试、手工验证 +和真实端到端运行使用。 diff --git a/scripts/a2a/debugger.md b/scripts/a2a/debugger.md index 621f6ddf..1026bbb0 100644 --- a/scripts/a2a/debugger.md +++ b/scripts/a2a/debugger.md @@ -6,7 +6,8 @@ Short local guide for `scripts/a2a/debugger.py`. ```bash PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ -uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 \ + --thinking-exposure raw-thinking --thinking-exposure tool-trace ``` For local smoke tests that should not stop on tool permissions: @@ -14,6 +15,9 @@ For local smoke tests that should not stop on tool permissions: ```bash cat >/tmp/iac-code-a2a-auto-approve.yml <<'EOF' auto_approve_permissions: true +thinking_exposure: + - raw-thinking + - tool-trace EOF PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ @@ -21,6 +25,11 @@ uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 \ --config /tmp/iac-code-a2a-auto-approve.yml ``` +The debugger's `Stream` button sends each message with +`metadata.iac_code.thinking.enabled=true`. The server still needs +`raw-thinking` in `thinking_exposure` to push `thinking_delta` events back to the +debugger. + ## Start the Debugger ```bash diff --git a/scripts/a2a/debugger.py b/scripts/a2a/debugger.py index d06b38ed..6c917bd6 100644 --- a/scripts/a2a/debugger.py +++ b/scripts/a2a/debugger.py @@ -178,12 +178,13 @@ def build_message_stream_payload( message_id: str, images: Any = None, iac_code_model: str | None = None, + thinking_enabled: bool = True, ) -> dict[str, Any]: parts = [] if prompt: parts.append({"text": prompt}) parts.extend(_normalize_image_parts(images)) - iac_code_metadata = {"cwd": cwd} + iac_code_metadata = {"cwd": cwd, "thinking": {"enabled": bool(thinking_enabled)}} if iac_code_model: stripped_model = iac_code_model.strip() if stripped_model: @@ -565,6 +566,26 @@ def render_index_html(config: DebuggerConfig) -> str: color: var(--danger); } + button.toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 112px; + font-weight: 700; + } + + button.toggle[aria-pressed="true"] { + border-color: #0f8b8d; + background: #e6f6f5; + color: #0f766e; + } + + button.toggle[aria-pressed="false"] { + background: #f8fafc; + color: #475569; + } + input, textarea { width: 100%; @@ -926,14 +947,22 @@ def render_index_html(config: DebuggerConfig) -> str: } .timeline-detail-body { - display: none; margin-top: 6px; border-top: 1px solid var(--line); padding-top: 6px; } - .timeline-detail-body.is-open { - display: block; + .timeline-detail-body summary { + margin-bottom: 6px; + color: var(--muted); + cursor: pointer; + font-size: 11px; + font-weight: 700; + list-style: none; + } + + .timeline-detail-body summary::-webkit-details-marker { + display: none; } .timeline-detail-body pre { @@ -963,6 +992,11 @@ def render_index_html(config: DebuggerConfig) -> str: background: #f0f9ff; } + .timeline-thinking { + border-color: #ddd6fe; + background: #faf5ff; + } + .timeline-tool { border-color: #bbf7d0; background: #f0fdf4; @@ -1422,6 +1456,7 @@ def render_index_html(config: DebuggerConfig) -> str:
+ @@ -1596,10 +1631,25 @@ def render_index_html(config: DebuggerConfig) -> str: contextId: byId("context-id").value.trim(), taskId: byId("task-id").value.trim(), activeTaskId: byId("active-task-id").value.trim(), + thinkingEnabled: byId("thinking-toggle").getAttribute("aria-pressed") === "true", prompt: byId("prompt").value }; } + function setThinkingToggle(enabled) { + const button = byId("thinking-toggle"); + button.setAttribute("aria-pressed", enabled ? "true" : "false"); + button.textContent = enabled ? "Thinking On" : "Thinking Off"; + button.title = enabled + ? "Send metadata.iac_code.thinking.enabled=true on the next Stream request" + : "Send metadata.iac_code.thinking.enabled=false on the next Stream request"; + } + + function toggleThinking() { + const enabled = byId("thinking-toggle").getAttribute("aria-pressed") !== "true"; + setThinkingToggle(enabled); + } + function selectedImageFiles() { const input = byId("image-input"); if (!input || !input.files) { @@ -2058,7 +2108,7 @@ def render_index_html(config: DebuggerConfig) -> str: } const envelope = extractPipelineEnvelope(payload); const eventType = envelope ? eventTypeOf(envelope) : ""; - if (eventType !== "text_delta" || streamEventCount % 8 === 0) { + if (!["text_delta", "thinking_delta"].includes(eventType) || streamEventCount % 8 === 0) { await nextBrowserPaint(); } } @@ -2213,14 +2263,15 @@ def render_index_html(config: DebuggerConfig) -> str: let currentGroup = null; events.forEach((event) => { const eventType = eventTypeFromRawItem(event); - if (eventType === "text_delta") { + if (eventType === "text_delta" || eventType === "thinking_delta") { const envelope = pipelineEnvelopeFromRawItem(event) || {}; - const key = textDeltaGroupKey(event); + const groupType = eventType === "thinking_delta" ? "thinking_delta_group" : "text_delta_group"; + const key = `${eventType}|${textDeltaGroupKey(event)}`; const text = textDeltaText(event); if (!currentGroup || currentGroup.key !== key) { currentGroup = flushTextDeltaGroup(grouped, currentGroup); currentGroup = { - type: "text_delta_group", + type: groupType, key, count: 0, text: "", @@ -2323,8 +2374,8 @@ def render_index_html(config: DebuggerConfig) -> str: } function rawEventLabel(row) { - if (row.type === "text_delta_group") { - return `text_delta x${row.count}`; + if (row.type === "text_delta_group" || row.type === "thinking_delta_group") { + return `${row.type === "thinking_delta_group" ? "thinking_delta" : "text_delta"} x${row.count}`; } if (row.type === "a2a_message_group") { return `agent message x${row.count}`; @@ -2337,7 +2388,7 @@ def render_index_html(config: DebuggerConfig) -> str: } function rawEventSummary(row) { - if (row.type === "text_delta_group") { + if (row.type === "text_delta_group" || row.type === "thinking_delta_group") { return row.text || "(empty text delta)"; } if (row.type === "a2a_message_group") { @@ -2382,7 +2433,7 @@ def render_index_html(config: DebuggerConfig) -> str: } function rawEventMeta(row) { - if (row.type === "text_delta_group") { + if (row.type === "text_delta_group" || row.type === "thinking_delta_group") { const range = row.firstSequence && row.lastSequence && row.firstSequence !== row.lastSequence ? `seq ${row.firstSequence}-${row.lastSequence}` @@ -2759,7 +2810,7 @@ def render_index_html(config: DebuggerConfig) -> str: if (type.startsWith("interrupt_") || envelope.scope === "interrupt") { return stepKey || pipeline.key; } - if (["text_delta", "tool_result", "permission_requested", "input_required"].includes(type)) { + if (["text_delta", "thinking_delta", "tool_result", "permission_requested", "input_required"].includes(type)) { return candidateStepKey || candidateKey || stepKey || pipeline.key; } if (type.startsWith("candidate_step_")) { @@ -2790,6 +2841,9 @@ def render_index_html(config: DebuggerConfig) -> str: if (type === "text_delta") { return {label: "text", text: data.text || "", className: "timeline-text"}; } + if (type === "thinking_delta") { + return {label: "thinking", text: data.text || "", className: "timeline-thinking"}; + } if (type === "tool_result") { return { label: `tool: ${data.toolName || data.tool_name || "tool"}`, @@ -2842,20 +2896,23 @@ def render_index_html(config: DebuggerConfig) -> str: } const type = eventTypeOf(envelope); const summary = summarizeTimelineEvent(envelope); - const textKey = `text:${targetKey}`; - if (type === "text_delta") { + if (type === "text_delta" || type === "thinking_delta") { + const deltaKind = type === "thinking_delta" ? "thinking_delta" : "text_delta"; + const otherDeltaKind = deltaKind === "thinking_delta" ? "text_delta" : "thinking_delta"; + const textKey = `${deltaKind}:${targetKey}`; + delete state.executionTree.textGroups[`${otherDeltaKind}:${targetKey}`]; let item = state.executionTree.textGroups[textKey]; if (!item) { item = { key: `${textKey}:${envelope.sequence || node.timeline.length}`, type, - label: "text", + label: summary.label, text: "", meta: "", - className: "timeline-text", + className: summary.className, count: 0, raw: { - eventType: "text_delta_group", + eventType: `${deltaKind}_group`, targetKey, text: "", events: [] @@ -2866,12 +2923,14 @@ def render_index_html(config: DebuggerConfig) -> str: } item.count += 1; item.text += summary.text || ""; - item.meta = `text_delta x${item.count}`; + item.meta = `${deltaKind} x${item.count}`; item.raw.count = item.count; item.raw.text = item.text; item.raw.events.push(envelope); } else { + const textKey = `text_delta:${targetKey}`; delete state.executionTree.textGroups[textKey]; + delete state.executionTree.textGroups[`thinking_delta:${targetKey}`]; node.timeline.push({ key: envelope.eventId || envelope.event_id || `${type}:${envelope.sequence || node.timeline.length}`, type, @@ -3317,23 +3376,46 @@ def render_index_html(config: DebuggerConfig) -> str: } function renderTimelineDetails(item) { - const detail = document.createElement("div"); - detail.className = "timeline-detail-body"; - detail.addEventListener("click", stopNestedTimelineToggle); - detail.addEventListener("toggle", stopNestedTimelineToggle); - if (state.expandedTimelineKeys.has(item.key)) { - detail.classList.add("is-open"); - } + const details = document.createElement("details"); + details.className = "timeline-detail-body"; + details.setAttribute("data-timeline-detail-key", item.key); + details.open = state.expandedTimelineKeys.has(item.key); + details.addEventListener("click", stopNestedTimelineToggle); + details.addEventListener("toggle", onTimelineDetailToggle); + const summary = document.createElement("summary"); + summary.textContent = "Raw detail"; const pre = document.createElement("pre"); pre.textContent = detailTextForTimelineItem(item); - detail.appendChild(pre); - return detail; + details.appendChild(summary); + details.appendChild(pre); + return details; } function stopNestedTimelineToggle(event) { event.stopPropagation(); } + function onTimelineDetailToggle(event) { + event.stopPropagation(); + const details = event.currentTarget; + const key = details.getAttribute("data-timeline-detail-key"); + if (!key) { + return; + } + if (details.open) { + state.expandedTimelineKeys.add(key); + } else { + state.expandedTimelineKeys.delete(key); + } + const button = details.parentElement + ? details.parentElement.querySelector(":scope > .timeline-item-title .timeline-details-button") + : null; + if (button) { + button.textContent = details.open ? "Hide" : "Details"; + button.setAttribute("aria-expanded", details.open ? "true" : "false"); + } + } + function renderTimelineItem(item) { const element = document.createElement("div"); element.className = `timeline-item ${item.className || ""}`.trim(); @@ -3350,15 +3432,15 @@ def render_index_html(config: DebuggerConfig) -> str: button.type = "button"; button.className = "timeline-details-button"; button.textContent = state.expandedTimelineKeys.has(item.key) ? "Hide" : "Details"; + button.setAttribute("aria-expanded", state.expandedTimelineKeys.has(item.key) ? "true" : "false"); button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - if (state.expandedTimelineKeys.has(item.key)) { - state.expandedTimelineKeys.delete(item.key); - } else { - state.expandedTimelineKeys.add(item.key); + const details = element.querySelector(":scope > .timeline-detail-body"); + if (!details) { + return; } - renderPipeline(); + details.open = !details.open; }); title.appendChild(label); actions.appendChild(meta); @@ -3966,7 +4048,11 @@ def render_index_html(config: DebuggerConfig) -> str: function appendRawEventBody(container, row) { const body = document.createElement("div"); body.className = "raw-event-body"; - if (row.type === "text_delta_group" || row.type === "a2a_message_group") { + if ( + row.type === "text_delta_group" || + row.type === "thinking_delta_group" || + row.type === "a2a_message_group" + ) { const textLabel = document.createElement("div"); textLabel.className = "raw-event-body-label"; textLabel.textContent = "merged text"; @@ -3995,7 +4081,7 @@ def render_index_html(config: DebuggerConfig) -> str: const value = row.value || {}; return ["request", row.at || index, value.method || "", requestPath(value)].join("|"); } - if (row.type === "text_delta_group") { + if (row.type === "text_delta_group" || row.type === "thinking_delta_group") { return ["sse_text", row.key || "", row.firstSequence || row.firstAt || index].join("|"); } if (row.type === "a2a_message_group") { @@ -4095,7 +4181,7 @@ def render_index_html(config: DebuggerConfig) -> str: details.setAttribute("data-raw-key", key); details.open = state.expandedRawEventKeys.has(key); details.addEventListener("toggle", onRawEventToggle); - if (row.type !== "text_delta_group") { + if (row.type !== "text_delta_group" && row.type !== "thinking_delta_group") { const envelope = pipelineEnvelopeFromRawItem(row.event); const eventType = eventTypeFromRawItem(row.event); if (permissionFromEnvelope(envelope)) { @@ -4284,6 +4370,7 @@ def render_index_html(config: DebuggerConfig) -> str: iacCodeModel: controls.iacCodeModel, contextId: controls.contextId || state.contextId, taskId: streamTaskIdForControls(controls), + thinkingEnabled: controls.thinkingEnabled, prompt: controls.prompt }; if (images.length) { @@ -4563,7 +4650,7 @@ def render_index_html(config: DebuggerConfig) -> str: if (imageInput) { imageInput.disabled = true; } - ["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { + ["health-button", "thinking-toggle", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { const button = byId(id); if (button) { button.disabled = true; @@ -4610,7 +4697,7 @@ def render_index_html(config: DebuggerConfig) -> str: if (imageInput) { imageInput.setAttribute("disabled", "disabled"); } - ["health-button", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { + ["health-button", "thinking-toggle", "stream-button", "fetch-state-button", "cancel-button"].forEach((id) => { const button = clone.querySelector(`#${cssEscape(id)}`); if (button) { button.setAttribute("disabled", "disabled"); @@ -4692,6 +4779,7 @@ def render_index_html(config: DebuggerConfig) -> str: byId("health-button").addEventListener("click", (event) => withButtonState(event.currentTarget, healthCheck)); byId("fetch-state-button").addEventListener("click", (event) => withButtonState(event.currentTarget, fetchState)); byId("cancel-button").addEventListener("click", (event) => withButtonState(event.currentTarget, cancelTask)); + byId("thinking-toggle").addEventListener("click", toggleThinking); byId("stream-button").addEventListener("click", (event) => withStreamAction(event.currentTarget, streamMessage)); byId("export-html-button").addEventListener("click", exportCurrentHtmlSnapshot); byId("image-input").addEventListener("change", updateImageSummary); @@ -4855,6 +4943,13 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]: prompt = str(body.get("prompt", "")) context_id = str(body.get("contextId", "")) task_id = str(body.get("taskId", "")) + thinking_value = body.get("thinkingEnabled", body.get("thinking_enabled", True)) + if thinking_value is None: + thinking_enabled = True + elif isinstance(thinking_value, str): + thinking_enabled = thinking_value.strip().lower() not in {"0", "false", "no", "off"} + else: + thinking_enabled = bool(thinking_value) if not cwd: raise ValueError("cwd is required") if not prompt and not body.get("images"): @@ -4868,6 +4963,7 @@ def _message_stream_body(body: dict[str, Any]) -> tuple[str, dict[str, Any]]: message_id=str(uuid.uuid4()), images=body.get("images"), iac_code_model=iac_code_model, + thinking_enabled=thinking_enabled, ) return server_url, payload diff --git a/scripts/a2a/debugger.zh-CN.md b/scripts/a2a/debugger.zh-CN.md new file mode 100644 index 00000000..d5af05df --- /dev/null +++ b/scripts/a2a/debugger.zh-CN.md @@ -0,0 +1,105 @@ +# A2A Pipeline Debugger + +`scripts/a2a/debugger.py` 的本地简明使用说明。 + +## 启动 iac-code A2A + +```bash +PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 \ + --thinking-exposure raw-thinking --thinking-exposure tool-trace +``` + +如果本地 smoke 测试不希望停在工具权限确认上: + +```bash +cat >/tmp/iac-code-a2a-auto-approve.yml <<'EOF' +auto_approve_permissions: true +thinking_exposure: + - raw-thinking + - tool-trace +EOF + +PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 \ + --config /tmp/iac-code-a2a-auto-approve.yml +``` + +Debugger 的 `Stream` 按钮会在每条消息里发送 +`metadata.iac_code.thinking.enabled=true`。Server 仍然需要在 `thinking_exposure` +中开启 `raw-thinking`,才会把 `thinking_delta` 事件推回 debugger。 + +## 启动 Debugger + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/debugger.py --port 41880 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "$PWD" +``` + +`--default-cwd` 会作为 `metadata.iac_code.cwd` 随每条消息发送给 A2A server。它表示 +task 在 server 侧使用的 workspace,不只是 debugger 自己的工作目录。 + +A2A server 会在运行 agent 前校验这个路径。默认情况下,server 接受自己的启动目录和 +Python 临时目录。如果 `--default-cwd` 位于允许的根目录下但目录尚不存在,server 可能会创建它。 +当解析后的路径逃逸允许根目录、无法创建或不能作为目录使用时,请求会被拒绝,并返回 +`Invalid A2A workspace metadata.`。 + +可使用以下任一方式: + +```bash +# 使用已运行 server 接受的 cwd 启动 debugger。 +uv run python scripts/a2a/debugger.py --port 41880 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "/path/to/server/workspace" +``` + +```bash +# 或在启动 server 时显式允许 debugger/client workspace。 +IACCODE_A2A_ALLOWED_CWDS="/path/to/server/workspace:/path/to/client/workspace" \ +IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 +``` + +打开: + +```text +http://127.0.0.1:41880 +``` + +## 基本流程 + +1. 点击 `Health` 验证 A2A server。 +2. 输入 prompt,然后点击 `Stream`。 +3. 使用 `Structured Pipeline` 查看可读的树形视图。 +4. 使用 `SSE Events` 查看原始事件行和可展开详情。 +5. 使用 `Fetch State` 加载当前 pipeline snapshot。 +6. 使用 `Cancel` 取消活跃 task。 +7. 使用 `Export HTML` 导出只读 snapshot 页面,便于分享。 + +## 日志与回放 + +Debugger 启动时会打印每次运行的日志目录,例如: + +```text +A2A pipeline debugger logs: /tmp/iac-code-a2a-debugger-runs/ +``` + +回放已保存的运行: + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/debugger.py --port 41880 \ + --load-log-dir /tmp/iac-code-a2a-debugger-runs/ +``` + +## 说明 + +- Debugger 是本地开发工具,不提供认证能力。 +- `contextId` 标识一次会话;`taskId` 标识一个 A2A task。 +- `pipeline_handoff_ready` 之后,后续消息通常会在同一个 context 中启动新的 normal-chat task。 +- 图片输入只接受支持的图片 MIME 类型:`image/png`、`image/jpeg`、`image/webp` 和 `image/gif`。 +- A2A part parser 限制:text inline/raw 和 text `file://` part 限制为 1 MiB;binary inline/raw/data part 限制为 5 MiB;binary `file://` part 限制为 25 MiB。Debugger 上传的每张图片限制为 5 MiB。 +- `file://` 图片输入必须解析到一个已存在的本地文件,并且同时位于请求 cwd 和配置的 A2A allowed cwd 根目录下。任一边界之外的本地 URL 都会被拒绝。 +- A2A debugger 会发送 image parts。Selling Console Web UI 当前只发送文本输入。 diff --git a/scripts/a2a/selling_console_web/README.zh-CN.md b/scripts/a2a/selling_console_web/README.zh-CN.md new file mode 100644 index 00000000..468e6a89 --- /dev/null +++ b/scripts/a2a/selling_console_web/README.zh-CN.md @@ -0,0 +1,36 @@ +# Selling Console Web + +`scripts/a2a/selling_console.py` 使用的独立静态前端。它用于驱动 selling pipeline、查看步骤进度、 +选择候选方案,并在部署后继续进入 normal chat。 + +当前 Web console 只发送文本输入。A2A image part 的覆盖请使用 `scripts/a2a/debugger.py`。 + +## 运行 + +从仓库根目录开始,先启动 A2A server: + +```bash +PATH="$HOME/.local/bin:$PATH" IAC_CODE_MODE=pipeline \ +uv run iac-code a2a --transport http --host 127.0.0.1 --port 41299 +``` + +然后启动 Web console: + +```bash +PATH="$HOME/.local/bin:$PATH" \ +uv run python scripts/a2a/selling_console.py --port 41980 \ + --default-server-url http://127.0.0.1:41299 \ + --default-cwd "$PWD" +``` + +然后打开 `http://127.0.0.1:41980`。 + +## 文件 + +- `index.html` 渲染页面外壳。 +- `styles.css` 包含布局、聊天、方案卡片和进度视觉样式。 +- `app.js` 处理 A2A stream 解析、UI 状态、调试控件和交互。 +- `design/` 保存进度变体的独立视觉探索稿。 + +调试面板默认折叠。只有在检查连接设置、进度变体参数、context ID 或最近 stream 诊断信息时, +才需要展开它。 diff --git a/scripts/observability/local_observe.zh-CN.md b/scripts/observability/local_observe.zh-CN.md new file mode 100644 index 00000000..5b1d2984 --- /dev/null +++ b/scripts/observability/local_observe.zh-CN.md @@ -0,0 +1,38 @@ +# Local Observe Tool + +`local_observe` 是一个小型本地 OTLP receiver 和 Web UI,用于测试 `iac-code` telemetry。 + +## 启动 + +```bash +uv run python scripts/observability/local_observe.py --port 4318 --no-open +``` + +打开 `http://127.0.0.1:4318`。 + +## 把 Telemetry 发送到本地工具 + +在运行 `iac-code` 的 shell 中设置: + +```bash +export IAC_CODE_TELEMETRY_ENDPOINT=http://127.0.0.1:4318 +export IAC_CODE_ENABLE_LOCAL_TELEMETRY=1 +unset DISABLE_TELEMETRY +``` + +如果需要包含原始 prompt、模型输出和工具 payload: + +```bash +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT +``` + +然后正常运行 `iac-code`。 + +## UI + +- 使用 **Demo debug off/on** 加载示例数据。 +- 使用 **Expected raw content** 检查 debug-off / debug-on 行为。 +- Pipeline records 会按 run、step、AgentLoop round 和 raw evidence 分组。 +- Pipeline 级证据会拆分为 lifecycle、pipeline 后的 normal chat,以及其他 session evidence。 +- 开始新的手工测试前,使用 **Clear** 清空数据。 +- 使用 **Export JSONL** 保存已捕获的记录。 diff --git a/src/iac_code/a2a/client.py b/src/iac_code/a2a/client.py index e4d892cb..c57cd971 100644 --- a/src/iac_code/a2a/client.py +++ b/src/iac_code/a2a/client.py @@ -133,6 +133,9 @@ async def send_message( context_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, ) -> A2AClientResponse: payload = self._message_payload( method="SendMessage", @@ -141,6 +144,9 @@ async def send_message( context_id=context_id, model=model, iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, ) transport = self._make_transport_client(url) response = await transport.send(payload) @@ -155,6 +161,9 @@ async def stream_message( context_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, ) -> AsyncIterator[dict[str, Any]]: payload = self._message_payload( method="SendStreamingMessage", @@ -163,6 +172,9 @@ async def stream_message( context_id=context_id, model=model, iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, ) transport = self._make_transport_client(url) async for event in transport.stream(payload): @@ -359,8 +371,11 @@ def _message_payload( context_id: str | None, model: str | None, iac_code_api_key: str | None, + thinking_enabled: bool | None, + thinking_effort: str | None, + thinking_budget: int | None, ) -> dict[str, Any]: - iac_code_metadata = {"cwd": cwd} + iac_code_metadata: dict[str, Any] = {"cwd": cwd} if model: stripped_model = model.strip() if stripped_model: @@ -369,6 +384,17 @@ def _message_payload( stripped_api_key = iac_code_api_key.strip() if stripped_api_key: iac_code_metadata["iac_code_api_key"] = stripped_api_key + thinking: dict[str, Any] = {} + if thinking_enabled is not None: + thinking["enabled"] = thinking_enabled + if thinking_effort: + stripped_effort = thinking_effort.strip() + if stripped_effort: + thinking["effort"] = stripped_effort + if isinstance(thinking_budget, int) and not isinstance(thinking_budget, bool) and thinking_budget > 0: + thinking["budget"] = thinking_budget + if thinking: + iac_code_metadata["thinking"] = thinking message: dict[str, Any] = { "messageId": str(uuid.uuid4()), "role": "ROLE_USER", diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py index 3ef32d89..25c6d4af 100644 --- a/src/iac_code/a2a/executor.py +++ b/src/iac_code/a2a/executor.py @@ -47,6 +47,7 @@ refresh_runtime_cloud_tools, ) from iac_code.a2a.task_store import A2ATaskStore, _close_runtime +from iac_code.a2a.thinking_metadata import A2AThinkingMetadata from iac_code.a2a.types import ( TASK_STATE_CANCELED, TASK_STATE_FAILED, @@ -74,6 +75,7 @@ mark_cleanup_prompt_message_completed, ) from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input +from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.capabilities.multimodal import is_model_multimodal from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential @@ -819,6 +821,7 @@ async def publish_initial_task_if_missing() -> None: user_id = self._resolve_user_id(metadata) metadata_model = self._resolve_model(metadata) metadata_api_key = self._resolve_api_key(metadata) + request_policy_override = self._resolve_request_policy(metadata) model = metadata_model or self._model aliyun_credential = self._resolve_aliyun_credential(metadata) pipeline_mode = get_run_mode() == RunMode.PIPELINE @@ -922,6 +925,7 @@ async def publish_initial_task_if_missing() -> None: aliyun_credential=aliyun_credential, model_from_metadata=metadata_model is not None, metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, ) await pipeline_executor.execute( context=context, @@ -1093,6 +1097,7 @@ def runtime_factory(session_id: str) -> Any: model, from_metadata=metadata_model is not None, metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, ) refresh_runtime_cloud_tools(runtime) cleanup_ledger = _cleanup_ledger_for_a2a_normal_chat(cwd=cwd, session_id=ctx.session_id) @@ -1305,6 +1310,9 @@ def _resolve_api_key(self, metadata: Any | None) -> str | None: return raw_api_key.strip() return None + def _resolve_request_policy(self, metadata: Any | None) -> ProviderRequestPolicy | None: + return A2AThinkingMetadata.request_policy_from_metadata(metadata) + def _resolve_aliyun_credential(self, metadata: Any | None) -> AliyunCredential | None: if metadata is not None and hasattr(metadata, "DESCRIPTOR"): metadata = MessageToDict(metadata, preserving_proto_field_name=False) @@ -1537,12 +1545,14 @@ def _configure_runtime_model( *, from_metadata: bool, metadata_api_key: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ) -> None: configure_runtime_model( runtime, model, from_metadata=from_metadata, metadata_api_key=metadata_api_key, + request_policy_override=request_policy_override, ) def _credentials_with_metadata_api_key( diff --git a/src/iac_code/a2a/pipeline_events.py b/src/iac_code/a2a/pipeline_events.py index defc72a9..5b51d682 100644 --- a/src/iac_code/a2a/pipeline_events.py +++ b/src/iac_code/a2a/pipeline_events.py @@ -20,6 +20,7 @@ PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent, + ThinkingDeltaEvent, ToolResultEvent, ToolUseEndEvent, ) @@ -283,6 +284,8 @@ def translate(self, event: Any) -> list[dict[str, Any]]: return self._translate_pipeline_event(event) if isinstance(event, TextDeltaEvent): return [self._translate_text_delta_event(event)] + if isinstance(event, ThinkingDeltaEvent): + return [self._translate_thinking_delta_event(event)] if isinstance(event, AskUserQuestionEvent): return [self._translate_ask_user_question_event(event)] if isinstance(event, PermissionRequestEvent): @@ -528,6 +531,11 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> data = {"text": inner.text} input_data = None permission = None + elif isinstance(inner, ThinkingDeltaEvent): + event_type = "thinking_delta" + data = _thinking_delta_data(inner) + input_data = None + permission = None elif isinstance(inner, AskUserQuestionEvent): event_type = "input_required" data = _ask_user_question_data(inner) @@ -599,6 +607,9 @@ def _translate_sub_pipeline_stream_event(self, event: SubPipelineStreamEvent) -> def _translate_text_delta_event(self, event: TextDeltaEvent) -> dict[str, Any]: return self._translate_parent_scoped_display_event("text_delta", {"text": event.text}) + def _translate_thinking_delta_event(self, event: ThinkingDeltaEvent) -> dict[str, Any]: + return self._translate_parent_scoped_display_event("thinking_delta", _thinking_delta_data(event)) + def _translate_ask_user_question_event(self, event: AskUserQuestionEvent) -> dict[str, Any]: envelope = self._translate_parent_scoped_display_event("input_required", _ask_user_question_data(event)) envelope["status"] = "input_required" @@ -1270,6 +1281,10 @@ def _tool_result_data(event: ToolResultEvent) -> dict[str, Any]: } +def _thinking_delta_data(event: ThinkingDeltaEvent) -> dict[str, Any]: + return {"type": "raw_thinking", "text": _truncate(event.text)} + + def _mcp_progress_data(event: MCPProgressEvent) -> dict[str, Any]: data: dict[str, Any] = { "toolName": "mcp__{}__{}".format(event.server_name, event.tool_name), diff --git a/src/iac_code/a2a/pipeline_executor.py b/src/iac_code/a2a/pipeline_executor.py index 2c0151b6..de697ce1 100644 --- a/src/iac_code/a2a/pipeline_executor.py +++ b/src/iac_code/a2a/pipeline_executor.py @@ -46,6 +46,7 @@ from iac_code.pipeline.engine.public_errors import public_error from iac_code.pipeline.engine.session import PipelineSession from iac_code.pipeline.engine.user_input import PipelineUserInput, normalize_pipeline_user_input +from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime from iac_code.services.providers.aliyun import AliyunCredential from iac_code.services.session_storage import SessionStorage @@ -174,6 +175,7 @@ def __init__( aliyun_credential: AliyunCredential | None = None, model_from_metadata: bool = False, metadata_api_key: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ) -> None: self._task_store = task_store self._model = model @@ -187,6 +189,7 @@ def __init__( self._aliyun_credential = aliyun_credential self._model_from_metadata = model_from_metadata self._metadata_api_key = metadata_api_key + self._request_policy_override = request_policy_override async def execute( self, @@ -233,6 +236,7 @@ def runtime_factory(session_id: str) -> Any: preserve_active_task = _is_active_task_record(task, ctx.active_task_id) if _is_active_task_request(task, task_id, ctx.active_task_id): with self._request_context(session_id=ctx.session_id): + self._configure_runtime_for_request(ctx.runtime) routed = await self._route_active_pipeline_interrupt( event_queue, task=task, @@ -282,14 +286,7 @@ def runtime_factory(session_id: str) -> Any: runtime=agent_runtime, iac_code_session_id=ctx.session_id, ) - configure_runtime_model( - agent_runtime, - self._model, - from_metadata=self._model_from_metadata, - metadata_api_key=self._metadata_api_key, - ) - if self._aliyun_credential is not None: - refresh_runtime_cloud_tools(agent_runtime) + self._configure_agent_runtime_for_request(agent_runtime) pipeline = self._create_pipeline( session_id=ctx.session_id, cwd=cwd, @@ -456,6 +453,22 @@ def _request_context(self, *, session_id: str | None = None) -> contextlib.Abstr aliyun_credential=self._aliyun_credential, ) + def _configure_runtime_for_request(self, runtime: Any) -> None: + agent_runtime = getattr(runtime, "agent_runtime", None) + if agent_runtime is not None: + self._configure_agent_runtime_for_request(agent_runtime) + + def _configure_agent_runtime_for_request(self, agent_runtime: Any) -> None: + configure_runtime_model( + agent_runtime, + self._model, + from_metadata=self._model_from_metadata, + metadata_api_key=self._metadata_api_key, + request_policy_override=self._request_policy_override, + ) + if self._aliyun_credential is not None: + refresh_runtime_cloud_tools(agent_runtime) + def _pipeline_runtime_from_context(self, runtime: Any, *, session_id: str, cwd: str) -> A2APipelineRuntime: if isinstance(runtime, A2APipelineRuntime): return runtime diff --git a/src/iac_code/a2a/pipeline_stream.py b/src/iac_code/a2a/pipeline_stream.py index 1dff82e3..8dccd40f 100644 --- a/src/iac_code/a2a/pipeline_stream.py +++ b/src/iac_code/a2a/pipeline_stream.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable from typing import Any -from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent +from a2a.types import TaskState, TaskStatus, TaskStatusUpdateEvent from google.protobuf.json_format import ParseDict from iac_code.a2a.events import ( @@ -15,7 +15,6 @@ _emit_auto_permission_audit, _emit_resolver_permission_audit, _extract_artifact_metadata, - make_text_part, ) from iac_code.a2a.exposure import A2AExposureType, normalize_a2a_exposure_types from iac_code.a2a.pipeline_events import PipelineEventTranslator, safe_permission_metadata @@ -64,6 +63,7 @@ "candidate_detail_shown", "diagram_shown", "permission_requested", + "thinking_delta", "text_delta", "tool_result", } @@ -116,7 +116,7 @@ async def publish( text_parts: list[str] = [] for envelope in envelopes: - if _should_skip_envelope(envelope): + if _should_skip_envelope(envelope, exposure_types=self.exposure_types): continue artifact_metadata = await self._maybe_externalize_artifact(envelope, tool_result) @@ -499,7 +499,6 @@ async def _enqueue_status(self, envelope: dict[str, Any]) -> None: context_id=context_id, status=TaskStatus( state=_a2a_task_state_name(envelope), - message=_message_for_envelope(envelope, task_id=task_id, context_id=context_id), ), ) ParseDict({"iac_code": {"pipeline": envelope}}, update.metadata) @@ -565,8 +564,13 @@ def is_recovery_semantic_event(envelope: dict[str, Any]) -> bool: return scope in _RECOVERY_STATE_SCOPES and status in _RECOVERY_STATE_STATUSES -def _should_skip_envelope(envelope: dict[str, Any]) -> bool: - return envelope.get("eventType") == "text_delta" and _text_from_envelope(envelope) == "" +def _should_skip_envelope(envelope: dict[str, Any], *, exposure_types: frozenset[A2AExposureType]) -> bool: + event_type = envelope.get("eventType") + if event_type == "text_delta": + return _text_from_envelope(envelope) == "" + if event_type == "thinking_delta": + return A2AExposureType.RAW_THINKING not in exposure_types + return False def _maybe_inject_test_fault(point: str) -> None: @@ -580,25 +584,6 @@ def _maybe_inject_test_fault(point: str) -> None: os._exit(97) -def _message_for_envelope( - envelope: dict[str, Any], - *, - task_id: str | None = None, - context_id: str | None = None, -) -> Message | None: - if envelope.get("eventType") != "text_delta": - return None - - message_task_id = task_id or str(envelope["taskId"]) - return Message( - message_id=f"{message_task_id}-pipeline-{envelope['sequence']}", - task_id=message_task_id, - context_id=context_id or str(envelope["contextId"]), - role=Role.ROLE_AGENT, - parts=[make_text_part(_text_from_envelope(envelope))], - ) - - def _text_from_envelope(envelope: dict[str, Any]) -> str: data = envelope.get("data") text = data.get("text") if isinstance(data, dict) else "" diff --git a/src/iac_code/a2a/runtime_overrides.py b/src/iac_code/a2a/runtime_overrides.py index 983d86da..6336539d 100644 --- a/src/iac_code/a2a/runtime_overrides.py +++ b/src/iac_code/a2a/runtime_overrides.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from typing import Any +from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.services.providers.aliyun import AliyunCredential, use_aliyun_credential from iac_code.services.telemetry import use_session_id, use_user_id @@ -46,6 +47,7 @@ def configure_runtime_model( *, from_metadata: bool, metadata_api_key: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ) -> None: provider_manager = getattr(runtime, "provider_manager", None) reconfigure = getattr(provider_manager, "reconfigure", None) @@ -54,7 +56,16 @@ def configure_runtime_model( was_metadata_model = bool(getattr(runtime, "_iac_code_a2a_metadata_model_applied", False)) has_metadata_api_key = metadata_api_key is not None was_metadata_api_key = bool(getattr(runtime, "_iac_code_a2a_metadata_api_key_applied", False)) - if not from_metadata and not was_metadata_model and not has_metadata_api_key and not was_metadata_api_key: + has_metadata_policy = request_policy_override is not None and request_policy_override.has_values + was_metadata_policy = bool(getattr(runtime, "_iac_code_a2a_metadata_request_policy_applied", False)) + if ( + not from_metadata + and not was_metadata_model + and not has_metadata_api_key + and not was_metadata_api_key + and not has_metadata_policy + and not was_metadata_policy + ): return from iac_code.config import load_credentials @@ -76,9 +87,19 @@ def configure_runtime_model( provider_key_override=provider_key_override, metadata_api_key=metadata_api_key, ) - reconfigure(model, credentials, provider_key_override, base_url_override) + if has_metadata_policy or was_metadata_policy: + reconfigure( + model, + credentials, + provider_key_override, + base_url_override, + request_policy_override=request_policy_override if has_metadata_policy else None, + ) + else: + reconfigure(model, credentials, provider_key_override, base_url_override) setattr(runtime, "_iac_code_a2a_metadata_model_applied", from_metadata) setattr(runtime, "_iac_code_a2a_metadata_api_key_applied", has_metadata_api_key) + setattr(runtime, "_iac_code_a2a_metadata_request_policy_applied", has_metadata_policy) def credentials_with_metadata_api_key( diff --git a/src/iac_code/a2a/thinking_metadata.py b/src/iac_code/a2a/thinking_metadata.py new file mode 100644 index 00000000..a33955bf --- /dev/null +++ b/src/iac_code/a2a/thinking_metadata.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from google.protobuf.json_format import MessageToDict + +from iac_code.providers.request_policy import ProviderRequestPolicy, bool_or_none, positive_int_or_none + + +class A2AThinkingMetadata: + @classmethod + def request_policy_from_metadata(cls, metadata: Any | None) -> ProviderRequestPolicy | None: + if metadata is not None and hasattr(metadata, "DESCRIPTOR"): + metadata = MessageToDict(metadata, preserving_proto_field_name=False) + if not isinstance(metadata, Mapping): + return None + raw_iac_meta = metadata.get("iac_code") + if not isinstance(raw_iac_meta, Mapping): + return None + + raw_thinking = raw_iac_meta.get("thinking") + thinking_meta = raw_thinking if isinstance(raw_thinking, Mapping) else {} + enabled = cls._bool_value(thinking_meta, "enabled", "thinking_enabled", "thinkingEnabled") + if enabled is None: + enabled = cls._bool_value(raw_iac_meta, "thinking_enabled", "thinkingEnabled") + effort = cls._string_value(thinking_meta, "effort", "thinking_effort", "thinkingEffort") + if effort is None: + effort = cls._string_value(raw_iac_meta, "thinking_effort", "thinkingEffort") + budget = cls._positive_int_value(thinking_meta, "budget", "thinking_budget", "thinkingBudget") + if budget is None: + budget = cls._positive_int_value(raw_iac_meta, "thinking_budget", "thinkingBudget") + + policy = ProviderRequestPolicy(thinking_enabled=enabled, effort=effort, thinking_budget=budget) + return policy if policy.has_values else None + + @staticmethod + def _string_value(metadata: Mapping[str, Any], *keys: str) -> str | None: + for key in keys: + value = metadata.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + @staticmethod + def _positive_int_value(metadata: Mapping[str, Any], *keys: str) -> int | None: + for key in keys: + value = positive_int_or_none(metadata.get(key)) + if value is not None: + return value + return None + + @staticmethod + def _bool_value(metadata: Mapping[str, Any], *keys: str) -> bool | None: + for key in keys: + value = bool_or_none(metadata.get(key)) + if value is not None: + return value + return None diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index 7ca48cf1..4eeb656a 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -105,6 +105,7 @@ def _with_trusted_read_directories(permission_context: Any, directories: list[st def _emit_no_prompt_permission_audit( *, session_id: str, + cwd: str, request: ToolCallRequest, permission: PermissionResult, decision: Literal["allow", "deny"], @@ -117,6 +118,7 @@ def _emit_no_prompt_permission_audit( result = emit_permission_audit( PermissionAuditRecord( session_id=session_id, + cwd=cwd, tool_name=request.name, tool_use_id=request.id, decision=decision, @@ -1019,6 +1021,7 @@ async def _run_streaming_inner( audit_context = { "session_id": self._session_id, + "cwd": context.cwd, "settings": perm_ctx.audit_settings if perm_ctx is not None else None, "metadata": permission.audit, } @@ -1026,6 +1029,7 @@ async def _run_streaming_inner( if permission.behavior == "allow": audit_ok = _emit_no_prompt_permission_audit( session_id=self._session_id, + cwd=context.cwd, request=request, permission=permission, decision="allow", @@ -1039,6 +1043,7 @@ async def _run_streaming_inner( if permission.behavior == "deny": _emit_no_prompt_permission_audit( session_id=self._session_id, + cwd=context.cwd, request=request, permission=permission, decision="deny", diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index d93e92bb..94e13a7d 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -850,6 +850,21 @@ def a2a_call( "--iac-code-api-key", help=_("API key metadata to send with this A2A request"), ), + thinking_enabled: bool | None = typer.Option( + None, + "--thinking-enabled/--no-thinking-enabled", + help="Whether to enable thinking metadata for this A2A request", + ), + thinking_effort: str = typer.Option( + "", + "--thinking-effort", + help="Thinking effort metadata to send with this A2A request", + ), + thinking_budget: int | None = typer.Option( + None, + "--thinking-budget", + help="Thinking budget metadata to send with this A2A request", + ), token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), @@ -887,6 +902,9 @@ def a2a_call( context_id = _a2a_config_value(ctx, config, "context_id", context_id) iac_code_model = _a2a_config_value(ctx, config, "iac_code_model", iac_code_model) iac_code_api_key = _a2a_config_value(ctx, config, "iac_code_api_key", iac_code_api_key) + thinking_enabled = _a2a_config_value(ctx, config, "thinking_enabled", thinking_enabled) + thinking_effort = _a2a_config_value(ctx, config, "thinking_effort", thinking_effort) + thinking_budget = _a2a_config_value(ctx, config, "thinking_budget", thinking_budget) timeout = _a2a_config_value(ctx, config, "timeout", timeout) stream = _a2a_config_value(ctx, config, "stream", stream) auth_options = _a2a_client_auth_options( @@ -925,6 +943,9 @@ def a2a_call( context_id=context_id or None, model=iac_code_model or None, iac_code_api_key=iac_code_api_key or None, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort or None, + thinking_budget=thinking_budget, token=auth_options["token"] or None, basic_username=auth_options["basic_username"] or None, basic_password=auth_options["basic_password"] or None, @@ -1590,6 +1611,9 @@ async def _run_a2a_call( context_id: str | None, model: str | None, iac_code_api_key: str | None, + thinking_enabled: bool | None, + thinking_effort: str | None, + thinking_budget: int | None, token: str | None, basic_username: str | None, basic_password: str | None, @@ -1629,6 +1653,9 @@ async def _run_a2a_call( context_id=context_id, model=model, iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, ): line = _format_a2a_stream_event(event) if stream_callback is not None: @@ -1643,6 +1670,9 @@ async def _run_a2a_call( context_id=context_id, model=model, iac_code_api_key=iac_code_api_key, + thinking_enabled=thinking_enabled, + thinking_effort=thinking_effort, + thinking_budget=thinking_budget, ) return response.text or json.dumps(response.payload, ensure_ascii=False, indent=2, sort_keys=True) finally: diff --git a/src/iac_code/providers/anthropic_provider.py b/src/iac_code/providers/anthropic_provider.py index 2f66883f..80b627cb 100644 --- a/src/iac_code/providers/anthropic_provider.py +++ b/src/iac_code/providers/anthropic_provider.py @@ -14,6 +14,8 @@ Provider, ToolDefinition, ) +from iac_code.providers.request_logging import log_provider_request_policy +from iac_code.providers.request_policy import bool_or_none, positive_int_or_none from iac_code.providers.thinking import ( ANTHROPIC_BUDGET, ThinkingFamily, @@ -50,12 +52,18 @@ def __init__( max_tokens: int = 8192, client: Any = None, effort: str | None = None, + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, provider_key: str = "anthropic", **kwargs: Any, ) -> None: self._model = model self._max_tokens = max_tokens self._effort = effort + self._thinking_enabled = bool_or_none(thinking_enabled) + normalized_thinking_budget = positive_int_or_none(thinking_budget) + if normalized_thinking_budget is not None: + self._thinking_budget = normalized_thinking_budget if client is not None: self._client = client else: @@ -76,30 +84,43 @@ def get_model_name(self) -> str: return self._model def _build_thinking_kwargs(self) -> dict[str, Any]: - spec = get_thinking_spec(self._PROVIDER_KEY, self._model) - if spec.family is not ThinkingFamily.ANTHROPIC: - return {} - effort = normalize_effort(self._effort) - if effort is None or effort == "auto": - return {} - budget = ANTHROPIC_BUDGET.get(effort) + budget = self._effective_thinking_budget() if budget is None: return {} return {"thinking": {"type": "enabled", "budget_tokens": budget}} - def _adjust_max_tokens(self, max_tokens: int) -> int: + def _effective_thinking_budget(self) -> int | None: + if self._thinking_disabled(): + return None + explicit_budget = getattr(self, "_thinking_budget", None) + if isinstance(explicit_budget, int) and explicit_budget > 0: + return explicit_budget spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.ANTHROPIC: - return max_tokens + return None effort = normalize_effort(self._effort) if effort is None or effort == "auto": - return max_tokens + effort = spec.default_effort.value if self._thinking_forced() and spec.default_effort is not None else None + if effort is None: + return None budget = ANTHROPIC_BUDGET.get(effort) + if budget is None: + return None + return budget + + def _adjust_max_tokens(self, max_tokens: int) -> int: + budget = self._effective_thinking_budget() if budget is None: return max_tokens min_max = budget + 4096 return max(max_tokens, min_max) + def _thinking_disabled(self) -> bool: + return bool_or_none(getattr(self, "_thinking_enabled", None)) is False + + def _thinking_forced(self) -> bool: + return bool_or_none(getattr(self, "_thinking_enabled", None)) is True + async def stream( self, messages: list[Message], @@ -109,6 +130,12 @@ async def stream( ) -> AsyncGenerator[StreamEvent, None]: kwargs = self._build_kwargs(messages, system, tools, max_tokens) + log_provider_request_policy( + self._PROVIDER_KEY, + self._model, + "messages.stream", + kwargs, + ) async with self._client.messages.stream(**kwargs) as stream: # Track current content block state current_tool_use_id: str | None = None @@ -182,6 +209,12 @@ async def complete( cache_policy: str = "default", ) -> NonStreamingResponse: kwargs = self._build_kwargs(messages, system, tools, max_tokens) + log_provider_request_policy( + self._PROVIDER_KEY, + self._model, + "messages.create", + kwargs, + ) response = await self._client.messages.create(**kwargs) text_parts: list[str] = [] diff --git a/src/iac_code/providers/azure_openai_provider.py b/src/iac_code/providers/azure_openai_provider.py index 40da8287..bdc72cb5 100644 --- a/src/iac_code/providers/azure_openai_provider.py +++ b/src/iac_code/providers/azure_openai_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "azure_openai", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/dashscope_provider.py b/src/iac_code/providers/dashscope_provider.py index a57f0cb4..aa6c4170 100644 --- a/src/iac_code/providers/dashscope_provider.py +++ b/src/iac_code/providers/dashscope_provider.py @@ -50,6 +50,7 @@ def __init__( effort: str | None = None, base_url: str = DASHSCOPE_BASE_URL, provider_key: str = "dashscope", + thinking_enabled: bool | None = None, thinking_budget: int | None = None, max_completion_tokens: int | None = None, **kwargs, @@ -59,6 +60,7 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, thinking_budget=thinking_budget, max_completion_tokens=max_completion_tokens, provider_key=provider_key, @@ -128,6 +130,8 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.DASHSCOPE: return {} + if self._thinking_disabled(): + return {"extra_body": {"enable_thinking": False}} extra_body: dict[str, Any] = {"enable_thinking": True} thinking_budget = self._effective_thinking_budget() if thinking_budget is not None: diff --git a/src/iac_code/providers/deepseek_provider.py b/src/iac_code/providers/deepseek_provider.py index 5c072a3f..9ff0abdb 100644 --- a/src/iac_code/providers/deepseek_provider.py +++ b/src/iac_code/providers/deepseek_provider.py @@ -29,6 +29,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "deepseek", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs, ) -> None: super().__init__( @@ -36,5 +39,8 @@ def __init__( api_key=api_key, base_url=base_url or DEEPSEEK_BASE_URL, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, provider_key=provider_key, ) diff --git a/src/iac_code/providers/gemini_provider.py b/src/iac_code/providers/gemini_provider.py index a36b0a4e..03a7d491 100644 --- a/src/iac_code/providers/gemini_provider.py +++ b/src/iac_code/providers/gemini_provider.py @@ -5,7 +5,7 @@ from typing import Any from iac_code.providers.openai_provider import OpenAIProvider -from iac_code.providers.thinking import ThinkingFamily, get_thinking_spec, normalize_effort +from iac_code.providers.thinking import ThinkingFamily, get_thinking_spec GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai" @@ -23,6 +23,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "gemini", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs, ) -> None: super().__init__( @@ -30,6 +33,9 @@ def __init__( api_key=api_key, base_url=base_url or GEMINI_BASE_URL, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, provider_key=provider_key, ) @@ -37,8 +43,10 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.GEMINI: return {} - effort = normalize_effort(self._effort) - if effort is None or effort == "auto": + if self._thinking_disabled(): + return {} + effort = self._effective_effort(spec) + if effort is None: return {} allowed = {e.value for e in spec.allowed_efforts} if effort not in allowed: diff --git a/src/iac_code/providers/kimi_provider.py b/src/iac_code/providers/kimi_provider.py index 5b02837f..2a36472d 100644 --- a/src/iac_code/providers/kimi_provider.py +++ b/src/iac_code/providers/kimi_provider.py @@ -20,6 +20,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "kimi_cn", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -27,6 +30,9 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, provider_key=provider_key, ) self._PROVIDER_KEY = provider_key @@ -35,6 +41,8 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.KIMI: return {} + if self._thinking_disabled(): + return {"extra_body": {"thinking": {"type": "disabled"}}} if self._model == "kimi-k2.7-code": return {} return {"extra_body": {"thinking": {"type": "enabled"}}} diff --git a/src/iac_code/providers/lmstudio_provider.py b/src/iac_code/providers/lmstudio_provider.py index 92842243..4edd373b 100644 --- a/src/iac_code/providers/lmstudio_provider.py +++ b/src/iac_code/providers/lmstudio_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "lmstudio", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key or "lm-studio", base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/manager.py b/src/iac_code/providers/manager.py index 8a76698e..ec67d69b 100644 --- a/src/iac_code/providers/manager.py +++ b/src/iac_code/providers/manager.py @@ -12,6 +12,7 @@ from iac_code.i18n import _ from iac_code.providers.base import Message, NonStreamingResponse, Provider, ToolDefinition +from iac_code.providers.request_policy import ProviderRequestPolicy, bool_or_none from iac_code.providers.retry import RetryableError, RetryConfig, with_retry from iac_code.providers.stream_watchdog import StreamWatchdog from iac_code.services.telemetry import add_metric, get_session_id, log_event, start_span @@ -106,6 +107,7 @@ def create_provider( *, base_url: str | None = None, provider_key_override: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ) -> Provider: from iac_code.providers.registry import PROVIDER_REGISTRY @@ -115,13 +117,6 @@ def create_provider( raise ProviderNotConfiguredError( _("Unknown provider key: '{key}'. Run /auth to configure.").format(key=provider_key) ) - api_key = credentials.get(provider_key, "") - if desc.require_api_key and not api_key: - raise ProviderNotConfiguredError( - _("No API key configured for provider '{provider}' (model: {model}). Run /auth to configure.").format( - provider=desc.display_name, model=model - ) - ) from iac_code.config import get_provider_config provider_cfg = get_provider_config(provider_key) @@ -130,25 +125,63 @@ def create_provider( saved_base = provider_cfg.get("apiBase") if isinstance(saved_base, str) and saved_base: effective_base_url = saved_base - provider_cls = _import_provider_class(desc.provider_class) effort_value = _get_provider_config_value(provider_cfg, model, "effort") effort = effort_value if isinstance(effort_value, str) else None - request_policy_kwargs: dict[str, int] = {} + if request_policy_override is not None and request_policy_override.effort is not None: + effort = request_policy_override.effort + thinking_enabled = _get_bool_provider_config_value(provider_cfg, model, "thinkingEnabled") + if request_policy_override is not None and request_policy_override.thinking_enabled is not None: + thinking_enabled = request_policy_override.thinking_enabled + thinking_budget = _get_positive_int_provider_config_value(provider_cfg, model, "thinkingBudget") + max_completion_tokens = _get_positive_int_provider_config_value(provider_cfg, model, "maxCompletionTokens") + if request_policy_override is not None and request_policy_override.thinking_budget is not None: + thinking_budget = request_policy_override.thinking_budget + if request_policy_override is not None and request_policy_override.max_completion_tokens is not None: + max_completion_tokens = request_policy_override.max_completion_tokens + wire_provider_key = _wire_provider_key_for_openai_compatible_base( + provider_key, + effective_base_url, + ) + api_key = credentials.get(provider_key, "") + if not api_key and wire_provider_key != provider_key: + api_key = credentials.get(wire_provider_key, "") + if desc.require_api_key and not api_key: + raise ProviderNotConfiguredError( + _("No API key configured for provider '{provider}' (model: {model}). Run /auth to configure.").format( + provider=desc.display_name, model=model + ) + ) + provider_class_path = desc.provider_class + if wire_provider_key != provider_key: + wire_desc = PROVIDER_REGISTRY.get(wire_provider_key) + if wire_desc is not None: + provider_class_path = wire_desc.provider_class + provider_cls = _import_provider_class(provider_class_path) + request_policy_kwargs: dict[str, Any] = {} + if thinking_enabled is not None: + request_policy_kwargs["thinking_enabled"] = thinking_enabled from iac_code.providers.openai_provider import OpenAIProvider if issubclass(provider_cls, OpenAIProvider): - thinking_budget = _get_positive_int_provider_config_value(provider_cfg, model, "thinkingBudget") - max_completion_tokens = _get_positive_int_provider_config_value(provider_cfg, model, "maxCompletionTokens") if thinking_budget is not None: request_policy_kwargs["thinking_budget"] = thinking_budget if max_completion_tokens is not None: request_policy_kwargs["max_completion_tokens"] = max_completion_tokens + else: + from iac_code.providers.anthropic_provider import AnthropicProvider + + if ( + request_policy_override is not None + and request_policy_override.thinking_budget is not None + and issubclass(provider_cls, AnthropicProvider) + ): + request_policy_kwargs["thinking_budget"] = request_policy_override.thinking_budget return provider_cls( model=model, api_key=api_key or None, base_url=effective_base_url, effort=effort, - provider_key=provider_key, + provider_key=wire_provider_key, **request_policy_kwargs, ) @@ -186,6 +219,29 @@ def _get_positive_int_provider_config_value(provider_cfg: dict[str, Any], model: return _positive_int_or_none(provider_cfg.get(key)) +def _get_bool_provider_config_value(provider_cfg: dict[str, Any], model: str, key: str) -> bool | None: + model_cfg = _get_model_provider_config(provider_cfg, model) + if key in model_cfg: + model_value = bool_or_none(model_cfg[key]) + if model_value is not None: + return model_value + return bool_or_none(provider_cfg.get(key)) + + +def _wire_provider_key_for_openai_compatible_base( + provider_key: str, + base_url: str | None, +) -> str: + if provider_key != "openai_compatible" or not isinstance(base_url, str): + return provider_key + lower_base_url = base_url.lower() + if "token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode" in lower_base_url: + return "dashscope_token_plan" + if "dashscope.aliyuncs.com/compatible-mode" in lower_base_url: + return "dashscope" + return provider_key + + def _positive_int_or_none(value: Any) -> int | None: if isinstance(value, bool): return None @@ -200,6 +256,12 @@ def _positive_int_or_none(value: Any) -> int | None: return parsed if parsed > 0 else None +def _active_request_policy(policy: ProviderRequestPolicy | None) -> ProviderRequestPolicy | None: + if policy is None or not policy.has_values: + return None + return policy + + class ProviderManager: """Manages provider lifecycle, streaming fallback, and model degradation. When streaming fails mid-way: @@ -216,6 +278,7 @@ def __init__( stream_idle_timeout: float = 90.0, provider_key_override: str | None = None, base_url_override: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ): self._model = model self._credentials = credentials @@ -223,6 +286,7 @@ def __init__( self._stream_idle_timeout = stream_idle_timeout self._provider_key_override = provider_key_override self._base_url_override = base_url_override + self._request_policy_override = _active_request_policy(request_policy_override) # Lazy: first startup may have no active provider yet. Defer errors # until the user actually tries to send a message, so /auth is reachable. self._provider: Provider | None = None @@ -230,8 +294,7 @@ def __init__( self._provider = create_provider( model, credentials, - base_url=base_url_override, - provider_key_override=provider_key_override, + **self._provider_create_kwargs(), ) except ValueError as e: logger.warning(f"Provider not configured yet: {e}") @@ -262,17 +325,26 @@ def _ensure_provider(self) -> Provider: self._provider = create_provider( self._model, self._credentials, - base_url=self._base_url_override, - provider_key_override=self._provider_key_override, + **self._provider_create_kwargs(), ) return self._provider + def _provider_create_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "base_url": self._base_url_override, + "provider_key_override": self._provider_key_override, + } + if self._request_policy_override is not None: + kwargs["request_policy_override"] = self._request_policy_override + return kwargs + def reconfigure( self, model: str, credentials: dict[str, str], provider_key_override: str | None = None, base_url_override: str | None = None, + request_policy_override: ProviderRequestPolicy | None = None, ) -> None: """Switch model and credentials in place. @@ -285,13 +357,13 @@ def reconfigure( self._credentials = credentials self._provider_key_override = provider_key_override self._base_url_override = base_url_override + self._request_policy_override = _active_request_policy(request_policy_override) self._provider = None try: self._provider = create_provider( model, credentials, - base_url=base_url_override, - provider_key_override=provider_key_override, + **self._provider_create_kwargs(), ) except ValueError as e: logger.warning(f"Provider not configured after reconfigure: {e}") @@ -590,11 +662,16 @@ async def operation(): }, ) try: + fallback_kwargs: dict[str, Any] = { + "base_url": self._base_url_override, + "provider_key_override": self._provider_key_override, + } + if self._request_policy_override is not None: + fallback_kwargs["request_policy_override"] = self._request_policy_override fallback_provider = create_provider( fallback, self._credentials, - base_url=self._base_url_override, - provider_key_override=self._provider_key_override, + **fallback_kwargs, ) return await self._complete_with_retry_result( messages, diff --git a/src/iac_code/providers/minimax_provider.py b/src/iac_code/providers/minimax_provider.py index fc145ae8..5ab2c29d 100644 --- a/src/iac_code/providers/minimax_provider.py +++ b/src/iac_code/providers/minimax_provider.py @@ -20,6 +20,8 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "minimax_cn", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -27,6 +29,8 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, provider_key=provider_key, ) self._PROVIDER_KEY = provider_key @@ -35,6 +39,8 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.MINIMAX: return {} + if self._thinking_disabled(): + return {"thinking": {"type": "disabled"}} if self._model == "MiniMax-M3": return {"thinking": {"type": "adaptive"}} return {} diff --git a/src/iac_code/providers/modelscope_provider.py b/src/iac_code/providers/modelscope_provider.py index f75e3988..46f4799a 100644 --- a/src/iac_code/providers/modelscope_provider.py +++ b/src/iac_code/providers/modelscope_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "modelscope", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/ollama_provider.py b/src/iac_code/providers/ollama_provider.py index fa5bebe4..d1bda729 100644 --- a/src/iac_code/providers/ollama_provider.py +++ b/src/iac_code/providers/ollama_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "ollama", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key or "ollama", base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/openai_provider.py b/src/iac_code/providers/openai_provider.py index 383b44a0..9a333f08 100644 --- a/src/iac_code/providers/openai_provider.py +++ b/src/iac_code/providers/openai_provider.py @@ -17,6 +17,8 @@ Provider, ToolDefinition, ) +from iac_code.providers.request_logging import log_provider_request_policy +from iac_code.providers.request_policy import bool_or_none, positive_int_or_none from iac_code.providers.thinking import ThinkingFamily, get_thinking_spec, normalize_effort from iac_code.types.stream_events import ( MessageEndEvent, @@ -32,20 +34,6 @@ from iac_code.utils.tool_input_parser import parse_tool_input_events -def _positive_int_or_none(value: Any) -> int | None: - if isinstance(value, bool): - return None - if isinstance(value, int): - return value if value > 0 else None - if not isinstance(value, str): - return None - stripped = value.strip() - if not stripped.isdigit(): - return None - parsed = int(stripped) - return parsed if parsed > 0 else None - - class OpenAIProvider(Provider): """Provider implementation for OpenAI API (GPT-4, etc.).""" @@ -61,6 +49,7 @@ def __init__( base_url: str | None = None, client: Any = None, effort: str | None = None, + thinking_enabled: bool | None = None, thinking_budget: int | None = None, max_completion_tokens: int | None = None, provider_key: str = "openai", @@ -69,8 +58,9 @@ def __init__( self._model = model self._base_url = base_url self._effort = effort - self._thinking_budget = _positive_int_or_none(thinking_budget) - self._max_completion_tokens = _positive_int_or_none(max_completion_tokens) + self._thinking_enabled = bool_or_none(thinking_enabled) + self._thinking_budget = positive_int_or_none(thinking_budget) + self._max_completion_tokens = positive_int_or_none(max_completion_tokens) # Subclasses may set this before calling super().stream/complete to # inject provider-specific kwargs (e.g. DeepSeek thinking mode). self._extra_request_kwargs: dict[str, Any] = {} @@ -81,11 +71,17 @@ def __init__( self._PROVIDER_KEY = provider_key def _build_thinking_kwargs(self) -> dict[str, Any]: + compatible_kwargs = self._build_openai_compatible_thinking_kwargs() + if compatible_kwargs: + return compatible_kwargs + spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.OPENAI: return {} - effort = normalize_effort(self._effort) - if effort is None or effort == "auto": + if self._thinking_disabled(): + return {} + effort = self._effective_effort(spec) + if effort is None: return {} allowed = {e.value for e in spec.allowed_efforts} if effort not in allowed: @@ -101,13 +97,49 @@ def _effort_request_kwargs(self) -> dict[str, Any]: # Backwards-compatible alias used by the streaming/non-streaming paths. return self._build_thinking_kwargs() + def _build_openai_compatible_thinking_kwargs(self) -> dict[str, Any]: + if self._PROVIDER_KEY != "openai_compatible": + return {} + spec = get_thinking_spec("dashscope", self._model) + if spec.family is not ThinkingFamily.DASHSCOPE: + return {} + if self._thinking_disabled(): + return {"extra_body": {"enable_thinking": False}} + if ( + not self._thinking_forced() + and self._thinking_budget is None + and normalize_effort(self._effort) in {None, "auto"} + ): + return {} + + extra_body: dict[str, Any] = {"enable_thinking": True} + thinking_budget = self._effective_thinking_budget_for_spec(spec) + if thinking_budget is not None: + extra_body["thinking_budget"] = thinking_budget + kwargs: dict[str, Any] = {"extra_body": extra_body} + if spec.uses_reasoning_effort_param: + effort = normalize_effort(self._effort) + allowed = {e.value for e in spec.allowed_efforts} + if effort in allowed: + kwargs["reasoning_effort"] = effort + elif effort not in {None, "auto"} and spec.default_effort is not None: + kwargs["reasoning_effort"] = spec.default_effort.value + return kwargs + def _effective_thinking_budget(self) -> int | None: + if self._thinking_disabled(): + return None spec = get_thinking_spec(self._PROVIDER_KEY, self._model) + return self._effective_thinking_budget_for_spec(spec) + + def _effective_thinking_budget_for_spec(self, spec: Any) -> int | None: if not spec.supports_thinking_budget: return None return self._thinking_budget if self._thinking_budget is not None else spec.default_thinking_budget def _token_limit_kwargs(self, max_tokens: int) -> dict[str, int]: + if self._thinking_disabled(): + return {"max_tokens": max_tokens} spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if not spec.use_max_completion_tokens: return {"max_tokens": max_tokens} @@ -118,6 +150,18 @@ def _token_limit_kwargs(self, max_tokens: int) -> dict[str, int]: return {"max_tokens": max_tokens} return {"max_completion_tokens": max_tokens + thinking_budget} + def _thinking_disabled(self) -> bool: + return bool_or_none(getattr(self, "_thinking_enabled", None)) is False + + def _thinking_forced(self) -> bool: + return bool_or_none(getattr(self, "_thinking_enabled", None)) is True + + def _effective_effort(self, spec: Any) -> str | None: + effort = normalize_effort(self._effort) + if effort in {None, "auto"}: + return spec.default_effort.value if self._thinking_forced() and spec.default_effort is not None else None + return effort + def get_model_name(self) -> str: return self._model @@ -268,6 +312,12 @@ async def stream( usage = Usage() has_content = False + log_provider_request_policy( + self._PROVIDER_KEY, + self._model, + "chat.completions.stream", + kwargs, + ) response = await self._client.chat.completions.create(**kwargs) async for chunk in response: has_content = True @@ -377,6 +427,12 @@ async def complete( for k, v in self._extra_request_kwargs.items(): kwargs[k] = v + log_provider_request_policy( + self._PROVIDER_KEY, + self._model, + "chat.completions.create", + kwargs, + ) response = await self._client.chat.completions.create(**kwargs) if not hasattr(response, "choices"): base_url = str(self._base_url or self._client.base_url).rstrip("/") diff --git a/src/iac_code/providers/openrouter_provider.py b/src/iac_code/providers/openrouter_provider.py index 3ee5a24f..468449d7 100644 --- a/src/iac_code/providers/openrouter_provider.py +++ b/src/iac_code/providers/openrouter_provider.py @@ -21,6 +21,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "openrouter", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: client = kwargs.pop("client", None) @@ -39,5 +42,8 @@ def __init__( base_url=base_url, client=client, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, provider_key=provider_key, ) diff --git a/src/iac_code/providers/request_logging.py b/src/iac_code/providers/request_logging.py new file mode 100644 index 00000000..bb946d66 --- /dev/null +++ b/src/iac_code/providers/request_logging.py @@ -0,0 +1,97 @@ +"""Safe provider request logging helpers.""" + +from __future__ import annotations + +import json +from typing import Any + +from loguru import logger + +_LOG_PREFIX = "[provider-request-policy]" +_REQUEST_KEYS: tuple[str, ...] = ("stream", "max_tokens", "max_completion_tokens", "reasoning_effort") +_EXTRA_BODY_KEYS: tuple[str, ...] = ("enable_thinking", "thinking_budget", "thinking") +_THINKING_KEYS: tuple[str, ...] = ("type", "budget_tokens") + + +class ProviderRequestLogSanitizer: + """Build a safe, low-cardinality log payload for provider SDK calls.""" + + def __init__(self, provider_key: str, model: str, operation: str, kwargs: dict[str, Any]) -> None: + self._provider_key = provider_key + self._model = model + self._operation = operation + self._kwargs = kwargs + + def payload(self) -> dict[str, Any]: + request: dict[str, Any] = {} + for key in _REQUEST_KEYS: + if key in self._kwargs: + request[key] = self._json_scalar(self._kwargs[key]) + + extra_body = self._sanitize_extra_body(self._kwargs.get("extra_body")) + if extra_body: + request["extra_body"] = extra_body + + thinking = self._sanitize_thinking(self._kwargs.get("thinking")) + if thinking: + request["thinking"] = thinking + + return { + "provider": self._provider_key, + "model": self._model, + "operation": self._operation, + "request": request, + } + + @classmethod + def _sanitize_extra_body(cls, value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + sanitized: dict[str, Any] = {} + for key in _EXTRA_BODY_KEYS: + if key not in value: + continue + if key == "thinking": + thinking = cls._sanitize_thinking(value.get(key)) + if thinking: + sanitized[key] = thinking + continue + sanitized[key] = cls._json_scalar(value[key]) + return sanitized + + @classmethod + def _sanitize_thinking(cls, value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + sanitized: dict[str, Any] = {} + for key in _THINKING_KEYS: + if key in value: + sanitized[key] = cls._json_scalar(value[key]) + return sanitized + + @staticmethod + def _json_scalar(value: Any) -> str | int | float | bool | None: + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def build_provider_request_policy_log( + provider_key: str, + model: str, + operation: str, + kwargs: dict[str, Any], +) -> dict[str, Any]: + """Return the safe provider request policy payload without writing logs.""" + return ProviderRequestLogSanitizer(provider_key, model, operation, kwargs).payload() + + +def log_provider_request_policy( + provider_key: str, + model: str, + operation: str, + kwargs: dict[str, Any], +) -> None: + """Log a sanitized SDK call payload for request-policy diagnostics.""" + payload = build_provider_request_policy_log(provider_key, model, operation, kwargs) + logger.info("{} {}", _LOG_PREFIX, json.dumps(payload, ensure_ascii=False, sort_keys=True)) diff --git a/src/iac_code/providers/request_policy.py b/src/iac_code/providers/request_policy.py new file mode 100644 index 00000000..c2a32ae9 --- /dev/null +++ b/src/iac_code/providers/request_policy.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProviderRequestPolicy: + thinking_enabled: bool | None = None + effort: str | None = None + thinking_budget: int | None = None + max_completion_tokens: int | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "thinking_enabled", bool_or_none(self.thinking_enabled)) + object.__setattr__(self, "effort", _stripped_or_none(self.effort)) + object.__setattr__(self, "thinking_budget", positive_int_or_none(self.thinking_budget)) + object.__setattr__(self, "max_completion_tokens", positive_int_or_none(self.max_completion_tokens)) + + @property + def has_values(self) -> bool: + return ( + self.thinking_enabled is not None + or self.effort is not None + or self.thinking_budget is not None + or self.max_completion_tokens is not None + ) + + +def _stripped_or_none(value: Any) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def bool_or_none(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if not isinstance(value, str): + return None + stripped = value.strip().lower() + if stripped in {"1", "true", "t", "yes", "y", "on", "enable", "enabled"}: + return True + if stripped in {"0", "false", "f", "no", "n", "off", "disable", "disabled"}: + return False + return None + + +def positive_int_or_none(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + if isinstance(value, float): + return int(value) if value > 0 and value.is_integer() else None + if not isinstance(value, str): + return None + stripped = value.strip() + if not stripped.isdigit(): + return None + parsed = int(stripped) + return parsed if parsed > 0 else None diff --git a/src/iac_code/providers/siliconflow_provider.py b/src/iac_code/providers/siliconflow_provider.py index 9ff4a930..a03ef721 100644 --- a/src/iac_code/providers/siliconflow_provider.py +++ b/src/iac_code/providers/siliconflow_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "siliconflow_cn", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/volcengine_provider.py b/src/iac_code/providers/volcengine_provider.py index 79b40295..dc5e0857 100644 --- a/src/iac_code/providers/volcengine_provider.py +++ b/src/iac_code/providers/volcengine_provider.py @@ -19,6 +19,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "volcengine_cn", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -26,5 +29,8 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, + provider_key=provider_key, ) - self._PROVIDER_KEY = provider_key diff --git a/src/iac_code/providers/zhipu_provider.py b/src/iac_code/providers/zhipu_provider.py index e61c9f5e..9e8602ec 100644 --- a/src/iac_code/providers/zhipu_provider.py +++ b/src/iac_code/providers/zhipu_provider.py @@ -20,6 +20,9 @@ def __init__( base_url: str | None = None, effort: str | None = None, provider_key: str = "zhipu_cn", + thinking_enabled: bool | None = None, + thinking_budget: int | None = None, + max_completion_tokens: int | None = None, **kwargs: Any, ) -> None: super().__init__( @@ -27,6 +30,9 @@ def __init__( api_key=api_key, base_url=base_url, effort=effort, + thinking_enabled=thinking_enabled, + thinking_budget=thinking_budget, + max_completion_tokens=max_completion_tokens, provider_key=provider_key, ) self._PROVIDER_KEY = provider_key @@ -35,4 +41,6 @@ def _build_thinking_kwargs(self) -> dict[str, Any]: spec = get_thinking_spec(self._PROVIDER_KEY, self._model) if spec.family is not ThinkingFamily.ZHIPU: return {} + if self._thinking_disabled(): + return {"extra_body": {"thinking": {"type": "disabled"}}} return {"extra_body": {"thinking": {"type": "enabled"}}} diff --git a/src/iac_code/services/permissions/audit.py b/src/iac_code/services/permissions/audit.py index ec77df02..8c7a6001 100644 --- a/src/iac_code/services/permissions/audit.py +++ b/src/iac_code/services/permissions/audit.py @@ -5,7 +5,6 @@ import hashlib import os import re -import stat from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -13,7 +12,7 @@ from loguru import logger -from iac_code.config import get_config_dir +from iac_code.services.session_storage import SessionStorage from iac_code.services.telemetry import log_event from iac_code.services.telemetry.names import Events from iac_code.types.permissions import MAX_PERMISSION_AUDIT_FILES, PermissionAuditSettings @@ -182,6 +181,7 @@ class PermissionAuditRecord: decision: Literal["allow", "deny"] scope: str source: str + cwd: str = "" rule_source: str | None = None reason_type: str | None = None reason_detail: str | None = None @@ -546,6 +546,7 @@ def emit_permission_boundary_audit( result = emit_permission_audit( PermissionAuditRecord( session_id=_permission_audit_session_id(event, fallback=session_id), + cwd=_permission_audit_cwd(event), tool_name=event.tool_name, tool_use_id=event.tool_use_id, decision=decision, @@ -597,6 +598,7 @@ def emit_auto_permission_audit( result = emit_permission_audit( PermissionAuditRecord( session_id=_permission_audit_session_id(event, fallback=session_id), + cwd=_permission_audit_cwd(event), tool_name=event.tool_name, tool_use_id=event.tool_use_id, decision=decision, @@ -644,6 +646,11 @@ def _permission_audit_session_id(event: Any, *, fallback: str) -> str: return session_id if isinstance(session_id, str) else fallback +def _permission_audit_cwd(event: Any) -> str: + cwd = _permission_audit_context(event).get("cwd") + return cwd if isinstance(cwd, str) else "" + + def emit_permission_audit(record: PermissionAuditRecord, settings: PermissionAuditSettings | None = None) -> bool: """Write a permission audit row and emit a telemetry event.""" @@ -653,7 +660,7 @@ def emit_permission_audit(record: PermissionAuditRecord, settings: PermissionAud log_written = False try: - log_path = _log_path() + log_path = _log_path(record) _ensure_existing_audit_log_files_private(log_path, max_files=max_files) append_jsonl_rotating_locked( log_path, @@ -1041,34 +1048,24 @@ def _event_name(record: PermissionAuditRecord) -> str: return Events.TOOL_PERMISSION_REJECTED -def _log_path() -> Path: - log_dir = ensure_private_dir(get_config_dir() / "logs") - return log_dir / "permission-audit.jsonl" +def _log_path(record: PermissionAuditRecord) -> Path: + cwd = record.cwd.strip() or os.getcwd() + session_id = record.session_id.strip() or "unknown-session" + session_dir = ensure_private_dir(SessionStorage().session_dir(cwd, session_id)) + return session_dir / "permission-audit.jsonl" def _ensure_audit_log_files_private(path: Path, *, max_files: int) -> None: ensure_private_file(path) - _assert_private_audit_file(path) for index in range(1, max_files + 1): rotated = path.with_name(f"{path.name}.{index}") ensure_private_file(rotated) - _assert_private_audit_file(rotated) def _ensure_existing_audit_log_files_private(path: Path, *, max_files: int) -> None: if path.exists(): ensure_private_file(path) - _assert_private_audit_file(path) for index in range(1, max_files + 1): rotated = path.with_name(f"{path.name}.{index}") if rotated.exists(): ensure_private_file(rotated) - _assert_private_audit_file(rotated) - - -def _assert_private_audit_file(path: Path) -> None: - if os.name == "nt" or not path.exists(): - return - mode = stat.S_IMODE(path.stat().st_mode) - if mode & 0o077: - raise PermissionError(f"Permission audit log file is not private: {path}") diff --git a/src/iac_code/ui/repl.py b/src/iac_code/ui/repl.py index 5e87296d..58b04090 100644 --- a/src/iac_code/ui/repl.py +++ b/src/iac_code/ui/repl.py @@ -1134,6 +1134,7 @@ async def _request_shell_escape_permission(self, tool, tool_input: dict) -> bool permission_result=permission, audit_context={ "session_id": getattr(self, "_session_id", "") or "", + "cwd": self._permission_audit_cwd(), "settings": audit_settings, "metadata": permission.audit, }, @@ -1160,6 +1161,7 @@ def _audit_shell_escape_permission( emit_permission_audit( PermissionAuditRecord( session_id=getattr(self, "_session_id", "") or "", + cwd=self._permission_audit_cwd(), tool_name="bash", tool_use_id="shell-escape", decision=decision, @@ -1178,6 +1180,11 @@ def _audit_shell_escape_permission( is not False ) + def _permission_audit_cwd(self) -> str: + from iac_code.pipeline.config import get_working_directory + + return get_working_directory() or getattr(self, "_original_cwd", "") or "" + def _is_pipeline_safe_command(self, user_input: str) -> bool: """Commands always allowed mid-pipeline regardless of allow_user_escapes.command.""" first = user_input.split(None, 1)[0] if user_input else "" diff --git a/tests/a2a/test_client.py b/tests/a2a/test_client.py index 00e5f3f8..037fe151 100644 --- a/tests/a2a/test_client.py +++ b/tests/a2a/test_client.py @@ -190,6 +190,44 @@ async def test_message_payload_includes_metadata_iac_code_api_key() -> None: } +@pytest.mark.asyncio +async def test_message_payload_includes_metadata_thinking_override() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + await client.send_message( + "http://remote/", + "hello", + cwd="/tmp/work", + thinking_enabled=True, + thinking_effort="high", + thinking_budget=2048, + ) + events = [ + event + async for event in client.stream_message( + "http://remote/", + "hello", + cwd="/tmp/work", + thinking_enabled=False, + thinking_effort="low", + thinking_budget=1024, + ) + ] + + assert events + send_payload = http.requests[-2][2] + stream_payload = http.requests[-1][2] + assert send_payload["params"]["message"]["metadata"]["iac_code"] == { + "cwd": "/tmp/work", + "thinking": {"enabled": True, "effort": "high", "budget": 2048}, + } + assert stream_payload["params"]["message"]["metadata"]["iac_code"] == { + "cwd": "/tmp/work", + "thinking": {"enabled": False, "effort": "low", "budget": 1024}, + } + + def test_response_text_extracts_from_task_history_agent_message() -> None: response = A2AClientResponse( payload={ diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py index 01ba1bb0..d5eb7b30 100644 --- a/tests/a2a/test_executor.py +++ b/tests/a2a/test_executor.py @@ -514,6 +514,7 @@ async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, "user_id": "client-user", "iac_code_model": "metadata-model", "iac_code_api_key": "metadata-api-key", + "thinking": {"enabled": True, "effort": "high", "budget": 2048}, "alibaba_cloud_access_key_id": "client-id", "alibaba_cloud_access_key_secret": "client-secret", "alibaba_cloud_region_id": "cn-beijing", @@ -529,6 +530,9 @@ async def execute(self, *, context, event_queue, task, task_id, context_id, cwd, assert init_kwargs["user_id"] == "client-user" assert init_kwargs["model_from_metadata"] is True assert init_kwargs["metadata_api_key"] == "metadata-api-key" + assert init_kwargs["request_policy_override"].thinking_enabled is True + assert init_kwargs["request_policy_override"].effort == "high" + assert init_kwargs["request_policy_override"].thinking_budget == 2048 assert init_kwargs["aliyun_credential"].access_key_id == "client-id" assert init_kwargs["aliyun_credential"].access_key_secret == "client-secret" assert init_kwargs["aliyun_credential"].region_id == "cn-beijing" @@ -1963,6 +1967,61 @@ def reconfigure(self, model, credentials, provider_key_override=None, base_url_o ] +@pytest.mark.asyncio +async def test_executor_reconfigures_cached_runtime_thinking_per_call( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + class FakeProviderManager: + def __init__(self) -> None: + self.calls: list[tuple[str, object | None]] = [] + + def reconfigure( + self, + model, + credentials, + provider_key_override=None, + base_url_override=None, + request_policy_override=None, + ): + self.calls.append((model, request_policy_override)) + + provider_manager = FakeProviderManager() + runtime = FakeRuntime( + agent_loop=FakeAgentLoop([TextDeltaEvent(text="ok")]), + session_id="session-1", + provider_manager=provider_manager, + ) + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.config.load_credentials", lambda model=None: {}) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + await executor.execute( + FakeRequestContext( + context_id="ctx-1", + metadata={ + "iac_code": { + "cwd": str(tmp_path), + "thinking": {"enabled": False, "effort": "high", "budget": 2048}, + } + }, + ), + FakeEventQueue(), + ) + await executor.execute( + FakeRequestContext(context_id="ctx-1", task_id="task-2", metadata={"iac_code": {"cwd": str(tmp_path)}}), + FakeEventQueue(), + ) + + first_policy = provider_manager.calls[0][1] + assert provider_manager.calls[0][0] == "qwen3.6-plus" + assert getattr(first_policy, "thinking_enabled", None) is False + assert getattr(first_policy, "effort", None) == "high" + assert getattr(first_policy, "thinking_budget", None) == 2048 + assert provider_manager.calls[1] == ("qwen3.6-plus", None) + + @pytest.mark.asyncio async def test_executor_applies_aliyun_metadata_to_task_credentials( monkeypatch: pytest.MonkeyPatch, tmp_path: Path diff --git a/tests/a2a/test_permission_audit.py b/tests/a2a/test_permission_audit.py index 83069000..34102df9 100644 --- a/tests/a2a/test_permission_audit.py +++ b/tests/a2a/test_permission_audit.py @@ -60,6 +60,7 @@ async def test_stream_event_auto_approve_is_audited(monkeypatch: pytest.MonkeyPa ) future: asyncio.Future[bool] = asyncio.get_running_loop().create_future() settings = PermissionAuditSettings(include_tool_input=True) + cwd = "/home/workspace/ctx-1" await publish_stream_event( queue, @@ -72,6 +73,7 @@ async def test_stream_event_auto_approve_is_audited(monkeypatch: pytest.MonkeyPa response_future=future, audit_context={ "session_id": "session-a2a", + "cwd": cwd, "settings": settings, "metadata": PermissionAuditMetadata( scope="settings_rule", @@ -93,6 +95,7 @@ async def test_stream_event_auto_approve_is_audited(monkeypatch: pytest.MonkeyPa record, settings_seen = audit_records[0] assert settings_seen is settings assert record.session_id == "session-a2a" + assert record.cwd == cwd assert record.source == "a2a_auto_approve" assert record.scope == "auto_approve" assert record.decision == "allow" diff --git a/tests/a2a/test_pipeline_debugger_script.py b/tests/a2a/test_pipeline_debugger_script.py index c2e57d9d..a0f572a9 100644 --- a/tests/a2a/test_pipeline_debugger_script.py +++ b/tests/a2a/test_pipeline_debugger_script.py @@ -231,7 +231,7 @@ def test_build_message_stream_payload_uses_a2a_v1_method_and_cwd_metadata() -> N assert message["messageId"] == "msg-1" assert message["role"] == "ROLE_USER" assert message["parts"] == [{"text": "帮我生成售卖 pipeline 方案"}] - assert message["metadata"] == {"iac_code": {"cwd": "/workspace/demo"}} + assert message["metadata"] == {"iac_code": {"cwd": "/workspace/demo", "thinking": {"enabled": True}}} assert message["contextId"] == "ctx-demo" assert message["taskId"] == "task-demo" assert payload["params"]["configuration"] == {"acceptedOutputModes": ["text/plain"]} @@ -254,10 +254,29 @@ def test_build_message_stream_payload_includes_iac_code_model_metadata() -> None "iac_code": { "cwd": "/workspace/demo", "iac_code_model": "kimi-k2.7-code", + "thinking": {"enabled": True}, } } +def test_build_message_stream_payload_allows_disabling_thinking_metadata() -> None: + debugger = load_debugger_module() + + payload = debugger.build_message_stream_payload( + cwd="/workspace/demo", + prompt="start pipeline", + context_id="ctx-demo", + task_id="task-demo", + request_id="req-1", + message_id="msg-1", + thinking_enabled=False, + ) + + assert payload["params"]["message"]["metadata"] == { + "iac_code": {"cwd": "/workspace/demo", "thinking": {"enabled": False}} + } + + def test_build_message_stream_payload_adds_image_data_parts() -> None: debugger = load_debugger_module() @@ -661,6 +680,28 @@ def test_index_html_groups_text_delta_events() -> None: assert expected in html +def test_index_html_groups_thinking_delta_events() -> None: + debugger = load_debugger_module() + + html = debugger.render_index_html( + debugger.DebuggerConfig( + host="127.0.0.1", + port=41880, + default_server_url="http://127.0.0.1:41299", + default_cwd="/workspace/demo", + ) + ) + + for expected in [ + '"thinking_delta_group"', + 'eventType === "text_delta" || eventType === "thinking_delta"', + 'row.type === "text_delta_group" || row.type === "thinking_delta_group"', + 'if (type === "text_delta" || type === "thinking_delta")', + 'type === "thinking_delta" ? "thinking_delta" : "text_delta"', + ]: + assert expected in html + + def test_index_html_surfaces_permission_guidance() -> None: debugger = load_debugger_module() @@ -842,6 +883,7 @@ def test_index_html_models_parallel_candidates_and_rollback_history() -> None: "tool_result", "permission_requested", "text_delta", + "thinking_delta", ]: assert expected in html @@ -864,7 +906,7 @@ def test_index_html_assigns_timeline_events_to_business_nodes() -> None: "state.executionTree.lastCandidateKey", "state.executionTree.lastCandidateStepKey", "candidateStepKey || candidateKey || stepKey || pipeline.key", - 'if (["text_delta", "tool_result", "permission_requested", "input_required"].includes(type))', + 'if (["text_delta", "thinking_delta", "tool_result", "permission_requested", "input_required"].includes(type))', "delete state.executionTree.textGroups[textKey]", ]: assert expected in html @@ -885,8 +927,12 @@ def test_index_html_renders_timeline_details_with_pretty_json() -> None: for expected in [ "function detailTextForTimelineItem", "function renderTimelineDetails", + 'document.createElement("details")', + 'document.createElement("summary")', "timeline-details-button", "timeline-detail-body", + "data-timeline-detail-key", + "details.open = state.expandedTimelineKeys.has(item.key);", "JSON.stringify(item.raw, null, 2)", 'button.type = "button"', ]: @@ -930,7 +976,10 @@ def test_index_html_timeline_details_button_does_not_toggle_parent_details() -> assert 'button.addEventListener("click", (event) => {' in html assert "event.preventDefault();" in html assert "event.stopPropagation();" in html - assert 'detail.addEventListener("toggle", stopNestedTimelineToggle);' in html + assert 'button.setAttribute("aria-expanded", state.expandedTimelineKeys.has(item.key) ? "true" : "false");' in html + assert "details.open = !details.open;" in html + assert 'details.addEventListener("toggle", onTimelineDetailToggle);' in html + assert "function onTimelineDetailToggle" in html def test_index_html_groups_normal_a2a_message_deltas_outside_pipeline_steps() -> None: @@ -1081,6 +1130,29 @@ def test_index_html_uses_captured_context_and_task_ids_for_requests() -> None: assert "controls.contextId || state.contextId" in html assert "contextId: controls.contextId || state.contextId," in html assert "taskId: streamTaskIdForControls(controls)," in html + assert "thinkingEnabled: controls.thinkingEnabled," in html + + +def test_index_html_exposes_thinking_toggle_for_stream_requests() -> None: + debugger = load_debugger_module() + + html = debugger.render_index_html( + debugger.DebuggerConfig( + host="127.0.0.1", + port=41880, + default_server_url="http://127.0.0.1:41299", + default_cwd="/workspace/demo", + ) + ) + + for expected in [ + 'id="thinking-toggle"', + 'aria-pressed="true"', + "function toggleThinking", + 'byId("thinking-toggle").addEventListener("click", toggleThinking);', + 'thinkingEnabled: byId("thinking-toggle").getAttribute("aria-pressed") === "true"', + ]: + assert expected in html def test_index_html_distinguishes_pipeline_and_active_task_ids() -> None: @@ -1163,7 +1235,8 @@ def test_index_html_yields_between_batched_sse_events() -> None: "async function yieldToBrowserAfterStreamEvent", "for (const chunk of chunks)", "await yieldToBrowserAfterStreamEvent(parsed, ++streamEventCount);", - 'eventType !== "text_delta" || streamEventCount % 8 === 0', + '["text_delta", "thinking_delta"].includes(eventType)', + "streamEventCount % 8 === 0", ]: assert expected in html assert "chunks.forEach((chunk) => {" not in html @@ -2101,7 +2174,38 @@ def test_message_stream_route_forwards_sse_and_uses_stream_payload() -> None: assert sent["method"] == "SendStreamingMessage" assert sent["params"]["message"]["contextId"] == "ctx-1" assert sent["params"]["message"]["taskId"] == "task-1" - assert sent["params"]["message"]["metadata"] == {"iac_code": {"cwd": "/workspace/demo"}} + assert sent["params"]["message"]["metadata"] == { + "iac_code": {"cwd": "/workspace/demo", "thinking": {"enabled": True}} + } + + +def test_message_stream_route_forwards_disabled_thinking_metadata() -> None: + debugger = load_debugger_module() + SseTargetHandler.requests = [] + + with serve_handler(SseTargetHandler) as target_url: + running = start_debugger_server(debugger) + try: + status, body = post_raw( + f"{running.url}/api/message/stream", + { + "serverUrl": target_url, + "cwd": "/workspace/demo", + "contextId": "ctx-1", + "taskId": "task-1", + "thinkingEnabled": False, + "prompt": "start pipeline", + }, + ) + finally: + running.close() + + assert status == 200 + assert "data: " in body + sent = json.loads(SseTargetHandler.requests[0]["body"]) + assert sent["params"]["message"]["metadata"] == { + "iac_code": {"cwd": "/workspace/demo", "thinking": {"enabled": False}} + } def test_message_stream_route_forwards_image_parts() -> None: diff --git a/tests/a2a/test_pipeline_events.py b/tests/a2a/test_pipeline_events.py index bd81c061..5c4886be 100644 --- a/tests/a2a/test_pipeline_events.py +++ b/tests/a2a/test_pipeline_events.py @@ -16,6 +16,7 @@ PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent, + ThinkingDeltaEvent, ToolResultEvent, ToolUseEndEvent, ) @@ -575,6 +576,53 @@ def test_candidate_stream_text_has_parent_and_candidate_coordinates() -> None: assert envelopes[0]["data"]["text"] == "hello" +def test_top_level_thinking_delta_has_pipeline_envelope() -> None: + translator = PipelineEventTranslator(_ctx()) + + envelopes = translator.translate(ThinkingDeltaEvent(text="thinking out loud")) + + assert len(envelopes) == 1 + envelope = envelopes[0] + assert envelope["eventType"] == "thinking_delta" + assert envelope["scope"] == "pipeline" + assert envelope["status"] == "working" + assert envelope["data"] == {"type": "raw_thinking", "text": "thinking out loud"} + + +def test_candidate_stream_thinking_has_parent_and_candidate_coordinates() -> None: + translator = PipelineEventTranslator(_ctx()) + translator.translate( + PipelineEvent( + type=PipelineEventType.SUB_PIPELINE_STARTED, + step_id=None, + timestamp=time.time(), + data={ + "sub_pipeline_id": "evaluate_candidate_abcd", + "candidate_index": 0, + "candidate_name": "low cost", + "sub_pipeline_name": "evaluate_candidate", + "total_steps": 3, + "parent_step_id": "evaluate_candidates", + }, + ) + ) + + envelopes = translator.translate( + SubPipelineStreamEvent( + sub_pipeline_id="evaluate_candidate_abcd", + candidate_index=0, + inner=ThinkingDeltaEvent(text="candidate reasoning"), + ) + ) + + assert envelopes[0]["eventType"] == "thinking_delta" + assert envelopes[0]["scope"] == "candidate" + assert envelopes[0]["step"]["id"] == "evaluate_candidates" + assert envelopes[0]["candidate"]["runId"] == "candidate-evaluate_candidate_abcd-0-1" + assert envelopes[0]["candidate"]["index"] == 0 + assert envelopes[0]["data"] == {"type": "raw_thinking", "text": "candidate reasoning"} + + def test_nested_sub_pipeline_permission_request_uses_inner_candidate_scope() -> None: translator = PipelineEventTranslator(_ctx()) translator.translate( diff --git a/tests/a2a/test_pipeline_executor.py b/tests/a2a/test_pipeline_executor.py index 09fec86f..480867a6 100644 --- a/tests/a2a/test_pipeline_executor.py +++ b/tests/a2a/test_pipeline_executor.py @@ -158,8 +158,16 @@ async def aclose(self) -> None: self.closed_event.set() +class FakeToolRegistry: + def register(self, tool) -> None: + pass + + def unregister(self, tool_name: str) -> None: + pass + + def _fake_runtime(): - return SimpleNamespace(provider_manager=object(), tool_registry=object()) + return SimpleNamespace(provider_manager=object(), tool_registry=FakeToolRegistry()) def _status_events(queue: FakeEventQueue) -> list[dict]: @@ -299,13 +307,21 @@ async def test_pipeline_executor_reconfigures_cached_runtime_model_and_api_key_p tmp_path: Path, ) -> None: from iac_code.a2a.pipeline_executor import IacCodeA2APipelineExecutor + from iac_code.providers.request_policy import ProviderRequestPolicy class FakeProviderManager: def __init__(self) -> None: - self.calls: list[tuple[str, dict[str, str]]] = [] + self.calls: list[tuple[str, dict[str, str], object | None]] = [] - def reconfigure(self, model, credentials, provider_key_override=None, base_url_override=None): - self.calls.append((model, dict(credentials))) + def reconfigure( + self, + model, + credentials, + provider_key_override=None, + base_url_override=None, + request_policy_override=None, + ): + self.calls.append((model, dict(credentials), request_policy_override)) provider_manager = FakeProviderManager() runtime = SimpleNamespace(provider_manager=provider_manager, tool_registry=object()) @@ -336,6 +352,7 @@ def fake_create_pipeline(*args, **kwargs): thinking_exposure_types=None, model_from_metadata=True, metadata_api_key="metadata-key", + request_policy_override=ProviderRequestPolicy(effort="high", thinking_budget=2048), ) await metadata_executor.execute( context=FakeRequestContext(context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}), @@ -368,10 +385,12 @@ def fake_create_pipeline(*args, **kwargs): prompt="继续", ) - assert provider_manager.calls == [ - ("qwen3.6-max", {"dashscope": "metadata-key"}), - ("qwen3.6-plus", {"dashscope": "fallback-key"}), - ] + first_policy = provider_manager.calls[0][2] + assert provider_manager.calls[0][0] == "qwen3.6-max" + assert provider_manager.calls[0][1] == {"dashscope": "metadata-key"} + assert getattr(first_policy, "effort", None) == "high" + assert getattr(first_policy, "thinking_budget", None) == 2048 + assert provider_manager.calls[1] == ("qwen3.6-plus", {"dashscope": "fallback-key"}, None) async def _wait_for_output_text(task, expected: str) -> None: @@ -2647,6 +2666,101 @@ async def handle_user_interrupt(self, message: str) -> SimpleNamespace: assert event_types == ["interrupt_received", "interrupt_classified"] +@pytest.mark.asyncio +async def test_pipeline_executor_reconfigures_active_interrupt_runtime_thinking_per_call( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator + from iac_code.a2a.pipeline_executor import A2APipelineRuntime, IacCodeA2APipelineExecutor + from iac_code.a2a.pipeline_journal import A2APipelineJournal + from iac_code.a2a.pipeline_snapshot import A2APipelineSnapshotStore + from iac_code.a2a.pipeline_stream import PipelineA2AEventPublisher + from iac_code.providers.request_policy import ProviderRequestPolicy + + class FakeProviderManager: + def __init__(self) -> None: + self.calls: list[tuple[str, object | None]] = [] + + def reconfigure( + self, + model, + credentials, + provider_key_override=None, + base_url_override=None, + request_policy_override=None, + ): + self.calls.append((model, request_policy_override)) + + class InterruptiblePipeline(FakePipeline): + async def handle_user_interrupt(self, message: str) -> SimpleNamespace: + return SimpleNamespace( + action="supplement", + reason="added context", + rollback_target=None, + candidate_scope=None, + ) + + provider_manager = FakeProviderManager() + agent_runtime = SimpleNamespace(provider_manager=provider_manager, tool_registry=object()) + monkeypatch.setattr("iac_code.config.load_credentials", lambda model=None: {}) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda _session_id: agent_runtime, + ) + ctx.active_task_id = "task-1" + + queue = FakeEventQueue() + pipeline = InterruptiblePipeline([TextDeltaEvent(text="running")], session_dir=tmp_path / "sidecar") + publisher = PipelineA2AEventPublisher( + event_queue=queue, + translator=PipelineEventTranslator( + PipelineA2AContext( + pipeline_run_id="ctx-1", + task_id="task-1", + context_id="ctx-1", + pipeline_name="selling", + ) + ), + journal=A2APipelineJournal(tmp_path / "pipeline"), + snapshot_store=A2APipelineSnapshotStore(tmp_path / "pipeline"), + ) + ctx.runtime = A2APipelineRuntime(agent_runtime=agent_runtime, pipeline=pipeline, publisher=publisher) + store.mirror_context(ctx) + + executor = IacCodeA2APipelineExecutor( + task_store=store, + model="qwen3.6-plus", + metrics=NoOpA2AMetrics(), + artifact_store=None, + push_notifier=None, + permission_resolver=None, + auto_approve_permissions=False, + thinking_exposure_types=None, + request_policy_override=ProviderRequestPolicy(thinking_enabled=False, effort="high", thinking_budget=2048), + ) + + await executor.execute( + context=FakeRequestContext(text="please change cpu", metadata={"iac_code": {"cwd": str(tmp_path)}}), + event_queue=queue, + task=task, + task_id="task-1", + context_id="ctx-1", + cwd=str(tmp_path), + prompt="please change cpu", + ) + + policy = provider_manager.calls[0][1] + assert provider_manager.calls[0][0] == "qwen3.6-plus" + assert getattr(policy, "thinking_enabled", None) is False + assert getattr(policy, "effort", None) == "high" + assert getattr(policy, "thinking_budget", None) == 2048 + + @pytest.mark.asyncio async def test_pipeline_executor_publishes_input_required_for_live_paused_interrupt(tmp_path: Path) -> None: from iac_code.a2a.pipeline_events import PipelineA2AContext, PipelineEventTranslator diff --git a/tests/a2a/test_pipeline_stream.py b/tests/a2a/test_pipeline_stream.py index 42c6ece7..852634dd 100644 --- a/tests/a2a/test_pipeline_stream.py +++ b/tests/a2a/test_pipeline_stream.py @@ -25,6 +25,7 @@ PermissionRequestEvent, SubPipelineStreamEvent, TextDeltaEvent, + ThinkingDeltaEvent, ToolResultEvent, ToolUseEndEvent, ) @@ -103,7 +104,7 @@ def test_unknown_working_step_event_is_recovery_semantic() -> None: @pytest.mark.asyncio -async def test_publish_text_writes_a2a_metadata_journal_and_snapshot(tmp_path: Path) -> None: +async def test_publish_text_writes_pipeline_metadata_without_duplicate_status_message(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) returned = await publisher.publish(TextDeltaEvent(text="hello")) @@ -112,8 +113,7 @@ async def test_publish_text_writes_a2a_metadata_journal_and_snapshot(tmp_path: P assert isinstance(queue.events[0], TaskStatusUpdateEvent) dumped = dump(queue.events[0]) assert dumped["status"]["state"] == "TASK_STATE_WORKING" - assert dumped["status"]["message"]["role"] == "ROLE_AGENT" - assert dumped["status"]["message"]["parts"][0]["text"] == "hello" + assert "message" not in dumped["status"] envelope = dumped["metadata"]["iac_code"]["pipeline"] assert envelope["eventType"] == "text_delta" assert envelope["data"]["text"] == "hello" @@ -183,6 +183,35 @@ async def test_publish_empty_text_delta_is_skipped(tmp_path: Path) -> None: assert publisher.snapshot_store.load() is None +@pytest.mark.asyncio +async def test_publish_thinking_delta_requires_raw_thinking_exposure(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path, exposure_types=[]) + + returned = await publisher.publish(ThinkingDeltaEvent(text="hidden reasoning")) + + assert returned is None + assert queue.events == [] + assert publisher.journal.read_all() == [] + assert publisher.snapshot_store.load() is None + + +@pytest.mark.asyncio +async def test_publish_thinking_delta_writes_pipeline_metadata_when_exposed(tmp_path: Path) -> None: + publisher, queue = _publisher(tmp_path, exposure_types=[A2AExposureType.RAW_THINKING]) + + returned = await publisher.publish(ThinkingDeltaEvent(text="visible reasoning")) + + assert returned is None + assert isinstance(queue.events[0], TaskStatusUpdateEvent) + dumped = dump(queue.events[0]) + assert dumped["status"]["state"] == "TASK_STATE_WORKING" + assert "message" not in dumped["status"] + envelope = dumped["metadata"]["iac_code"]["pipeline"] + assert envelope["eventType"] == "thinking_delta" + assert envelope["data"] == {"type": "raw_thinking", "text": "visible reasoning"} + assert publisher.journal.read_all()[0]["data"] == {"type": "raw_thinking", "text": "visible reasoning"} + + @pytest.mark.asyncio async def test_publish_top_level_candidate_detail_updates_metadata_and_snapshot(tmp_path: Path) -> None: publisher, queue = _publisher(tmp_path) diff --git a/tests/a2a/test_selling_console_script.py b/tests/a2a/test_selling_console_script.py index 89f4449c..81d9c8ed 100644 --- a/tests/a2a/test_selling_console_script.py +++ b/tests/a2a/test_selling_console_script.py @@ -403,7 +403,11 @@ def test_message_stream_route_forwards_sse_and_cwd_metadata() -> None: payload = json.loads(SseTargetHandler.requests[0]["body"]) assert payload["method"] == "SendStreamingMessage" assert payload["params"]["message"]["metadata"] == { - "iac_code": {"cwd": "/workspace/demo", "iac_code_model": "kimi-k2.7-code"} + "iac_code": { + "cwd": "/workspace/demo", + "iac_code_model": "kimi-k2.7-code", + "thinking": {"enabled": True}, + } } diff --git a/tests/a2a/test_thinking_metadata.py b/tests/a2a/test_thinking_metadata.py new file mode 100644 index 00000000..c2f4be6e --- /dev/null +++ b/tests/a2a/test_thinking_metadata.py @@ -0,0 +1,22 @@ +from iac_code.a2a.thinking_metadata import A2AThinkingMetadata + + +def test_thinking_metadata_accepts_integral_float_budget_from_a2a_boundary() -> None: + policy = A2AThinkingMetadata.request_policy_from_metadata( + {"iac_code": {"thinking": {"enabled": False, "effort": "low", "budget": 2048.0}}} + ) + + assert policy is not None + assert policy.thinking_enabled is False + assert policy.effort == "low" + assert policy.thinking_budget == 2048 + + +def test_thinking_metadata_accepts_flat_string_enabled_alias() -> None: + policy = A2AThinkingMetadata.request_policy_from_metadata( + {"iac_code": {"thinking_enabled": "true", "thinkingBudget": "1024"}} + ) + + assert policy is not None + assert policy.thinking_enabled is True + assert policy.thinking_budget == 1024 diff --git a/tests/agent/test_permission_audit_integration.py b/tests/agent/test_permission_audit_integration.py index 54359b6b..4204b90b 100644 --- a/tests/agent/test_permission_audit_integration.py +++ b/tests/agent/test_permission_audit_integration.py @@ -28,6 +28,7 @@ ToolUseStartEvent, Usage, ) +from iac_code.utils.project_paths import sanitize_path class FakeProvider: @@ -79,6 +80,10 @@ def _read_jsonl(path): return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] +def _session_audit_log_path(config_dir, cwd: str, session_id: str): + return config_dir / "projects" / sanitize_path(cwd) / session_id / "permission-audit.jsonl" + + def _tool_turn( tool_use_id: str = "t1", *, @@ -193,6 +198,7 @@ async def test_agent_loop_prompt_event_carries_internal_audit_context(tmp_path): [prompt] = _permission_requests(events) assert prompt.audit_context == { "session_id": "session-prompt", + "cwd": str(tmp_path), "settings": settings, "metadata": metadata, } @@ -223,6 +229,7 @@ async def test_agent_loop_bash_ask_rule_prompt_carries_rule_audit_context(tmp_pa [prompt] = _permission_requests(events) metadata = prompt.audit_context["metadata"] assert prompt.audit_context["session_id"] == "session-bash-ask" + assert prompt.audit_context["cwd"] == str(tmp_path) assert prompt.audit_context["settings"] is settings assert metadata.scope == "settings_rule" assert metadata.rule_source == "project_settings" @@ -302,7 +309,7 @@ async def test_agent_loop_aliyun_exact_allow_jsonl_includes_read_write_classific events = await _collect_events(loop, "run aliyun", permission_handler=Mock(return_value=False)) assert not _permission_requests(events) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_jsonl(_session_audit_log_path(tmp_path, str(tmp_path), "session-aliyun-allow")) assert row["tool_name"] == "aliyun_api" assert row["decision"] == "allow" assert row["scope"] == "settings_rule" @@ -342,7 +349,7 @@ async def test_agent_loop_aliyun_bypass_mode_allows_write_with_audit(monkeypatch assert not _permission_requests(events) assert any(isinstance(event, ToolResultEvent) and not event.is_error for event in events) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_jsonl(_session_audit_log_path(tmp_path, str(tmp_path), "session-aliyun-bypass")) assert row["tool_name"] == "aliyun_api" assert row["decision"] == "allow" assert row["scope"] == "mode" diff --git a/tests/cli/test_a2a_command.py b/tests/cli/test_a2a_command.py index d9a55f3a..69b17219 100644 --- a/tests/cli/test_a2a_command.py +++ b/tests/cli/test_a2a_command.py @@ -423,6 +423,9 @@ async def send_message( context_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, ): called["send"] = { "url": url, @@ -431,6 +434,9 @@ async def send_message( "context_id": context_id, "model": model, "iac_code_api_key": iac_code_api_key, + "thinking_enabled": thinking_enabled, + "thinking_effort": thinking_effort, + "thinking_budget": thinking_budget, } return SimpleNamespace(text="created stack", payload={"result": {"text": "created stack"}}) @@ -475,6 +481,11 @@ async def aclose(self) -> None: "metadata-model", "--iac-code-api-key", "metadata-api-key", + "--thinking-enabled", + "--thinking-effort", + "high", + "--thinking-budget", + "2048", "--token", "bearer", "--api-key", @@ -504,6 +515,9 @@ async def aclose(self) -> None: "context_id": "ctx-1", "model": "metadata-model", "iac_code_api_key": "metadata-api-key", + "thinking_enabled": True, + "thinking_effort": "high", + "thinking_budget": 2048, } assert called["discover"] == "http://agent.example/rpc" assert called["fallback_url"] == "http://agent.example/rpc" @@ -576,6 +590,9 @@ async def stream_message( context_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, ): called["stream"] = { "url": url, @@ -584,6 +601,9 @@ async def stream_message( "context_id": context_id, "model": model, "iac_code_api_key": iac_code_api_key, + "thinking_enabled": thinking_enabled, + "thinking_effort": thinking_effort, + "thinking_budget": thinking_budget, } yield {"result": {"status": {"state": "working", "message": {"parts": [{"text": "planning"}]}}}} yield {"result": {"text": "created stack"}} @@ -623,6 +643,9 @@ async def aclose(self) -> None: "context_id": "ctx-1", "model": None, "iac_code_api_key": None, + "thinking_enabled": None, + "thinking_effort": None, + "thinking_budget": None, } assert called["closed"] is True @@ -675,6 +698,9 @@ async def fake_run_a2a_call(**kwargs) -> str: "context-id: ctx-from-config", "iac-code-model: config-model", "iac-code-api-key: config-iac-code-api-key", + "thinking-enabled: false", + "thinking-effort: low", + "thinking-budget: 1024", "token: config-token", "basic-username: config-user", "basic-password: config-pass", @@ -713,6 +739,9 @@ async def fake_run_a2a_call(**kwargs) -> str: "context_id": "ctx-from-config", "model": "config-model", "iac_code_api_key": "config-iac-code-api-key", + "thinking_enabled": False, + "thinking_effort": "low", + "thinking_budget": 1024, "token": "config-token", "basic_username": "config-user", "basic_password": "config-pass", diff --git a/tests/mcp/test_oauth.py b/tests/mcp/test_oauth.py index 2940f27d..74a0af4f 100644 --- a/tests/mcp/test_oauth.py +++ b/tests/mcp/test_oauth.py @@ -210,17 +210,27 @@ async def refresh(): assert calls == 1 -def test_token_refresh_coordinator_deduplicates_across_event_loops() -> None: +def test_token_refresh_coordinator_deduplicates_across_event_loops(monkeypatch: pytest.MonkeyPatch) -> None: calls = 0 calls_lock = threading.Lock() + owner_started = threading.Event() release = threading.Event() + waiter_registered = threading.Event() coordinator = TokenRefreshCoordinator() + wrap_future = asyncio.wrap_future + + def observed_wrap_future(future, *, loop=None): + waiter_registered.set() + return wrap_future(future, loop=loop) + + monkeypatch.setattr(oauth_module.asyncio, "wrap_future", observed_wrap_future) async def refresh(): nonlocal calls with calls_lock: calls += 1 - await asyncio.to_thread(release.wait, 2) + owner_started.set() + await asyncio.to_thread(release.wait) return "new-token" def run_refresh() -> str: @@ -229,12 +239,11 @@ def run_refresh() -> str: with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: first = executor.submit(run_refresh) second = executor.submit(run_refresh) - while True: - with calls_lock: - if calls: - break - release.wait(0.01) - release.set() + try: + assert owner_started.wait(1) + assert waiter_registered.wait(1) + finally: + release.set() assert first.result(timeout=1) == "new-token" assert second.result(timeout=1) == "new-token" diff --git a/tests/providers/test_anthropic_provider.py b/tests/providers/test_anthropic_provider.py index 83f2e32e..19ace282 100644 --- a/tests/providers/test_anthropic_provider.py +++ b/tests/providers/test_anthropic_provider.py @@ -133,6 +133,34 @@ def test_no_effort_returns_empty(self): assert p._build_thinking_kwargs() == {} assert p._adjust_max_tokens(8192) == 8192 + def test_enabled_true_uses_default_effort(self): + from iac_code.providers.anthropic_provider import AnthropicProvider + + p = AnthropicProvider(model="claude-opus-4-7", api_key="k", thinking_enabled=True) + assert p._build_thinking_kwargs() == {"thinking": {"type": "enabled", "budget_tokens": 16384}} + assert p._adjust_max_tokens(8192) >= 16384 + 4096 + + def test_enabled_false_suppresses_effort_and_budget(self): + from iac_code.providers.anthropic_provider import AnthropicProvider + + p = AnthropicProvider( + model="claude-opus-4-7", + api_key="k", + effort="high", + thinking_budget=2048, + thinking_enabled=False, + ) + assert p._build_thinking_kwargs() == {} + assert p._adjust_max_tokens(8192) == 8192 + + def test_explicit_thinking_budget_enables_thinking_and_bumps_max(self): + from iac_code.providers.anthropic_provider import AnthropicProvider + + p = AnthropicProvider(model="claude-opus-4-7", api_key="k", thinking_budget=2048) + + assert p._build_thinking_kwargs() == {"thinking": {"type": "enabled", "budget_tokens": 2048}} + assert p._adjust_max_tokens(8192) >= 2048 + 4096 + @pytest.mark.asyncio class TestAnthropicStream: diff --git a/tests/providers/test_dashscope_provider.py b/tests/providers/test_dashscope_provider.py index 4da719ab..d753b44f 100644 --- a/tests/providers/test_dashscope_provider.py +++ b/tests/providers/test_dashscope_provider.py @@ -66,6 +66,10 @@ def test_qwen_returns_enable_thinking(self): p = DashScopeProvider(model="qwen3.6-plus", api_key="k") assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True}} + def test_enabled_false_returns_disable_thinking(self): + p = DashScopeProvider(model="qwen3.6-plus", api_key="k", thinking_enabled=False) + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": False}} + def test_qwen_with_effort_still_only_enable_thinking(self): # Bailian Qwen does not honor effort — provider ignores it gracefully. p = DashScopeProvider(model="qwen3.6-plus", api_key="k", effort="high") @@ -116,6 +120,25 @@ async def test_glm52_defaults_to_bounded_thinking_budget_and_max_completion_toke assert call_kwargs["extra_body"] == {"enable_thinking": True, "thinking_budget": 8192} assert "reasoning_effort" not in call_kwargs + async def test_glm52_enabled_false_disables_budget_and_max_completion_tokens(self): + chunks = [ + ns( + usage=ns(prompt_tokens=1, completion_tokens=1), + choices=[ns(finish_reason="stop", delta=ns(content="ok", tool_calls=None))], + ), + ] + client = FakeOpenAIClient(stream_chunks=chunks) + provider = DashScopeProvider(model="glm-5.2", api_key="k", effort="high", thinking_enabled=False) + provider._client = client + + _ = [event async for event in provider.stream(messages=[Message.user("hi")], system="", max_tokens=8192)] + + call_kwargs = client.chat.completions.calls[0] + assert call_kwargs["max_tokens"] == 8192 + assert "max_completion_tokens" not in call_kwargs + assert call_kwargs["extra_body"] == {"enable_thinking": False} + assert "reasoning_effort" not in call_kwargs + async def test_kimi_k27_code_defaults_to_bounded_thinking_budget_and_max_completion_tokens(self): chunks = [ ns( diff --git a/tests/providers/test_manager.py b/tests/providers/test_manager.py index dde4b9bd..616614fe 100644 --- a/tests/providers/test_manager.py +++ b/tests/providers/test_manager.py @@ -5,6 +5,7 @@ from iac_code.providers.base import Message, NonStreamingResponse from iac_code.providers.manager import ProviderManager, _detect_provider_name, create_provider +from iac_code.providers.request_policy import ProviderRequestPolicy from iac_code.types.stream_events import MessageEndEvent, MessageStartEvent, TextDeltaEvent, Usage STREAM_IDLE_TEST_TIMEOUT = 0.2 @@ -44,11 +45,13 @@ def test_model_config_overrides_provider_request_policy(self, monkeypatch): "iac_code.config.get_provider_config", lambda name: { "effort": "high", + "thinkingEnabled": False, "thinkingBudget": 4096, "maxCompletionTokens": 12288, "models": { "glm-5.2": { "effort": "low", + "thinkingEnabled": True, "thinkingBudget": 2048, "maxCompletionTokens": 10000, } @@ -59,9 +62,44 @@ def test_model_config_overrides_provider_request_policy(self, monkeypatch): p = create_provider("glm-5.2", credentials={"dashscope": "key"}) assert getattr(p, "_effort", None) == "low" + assert getattr(p, "_thinking_enabled", None) is True assert getattr(p, "_thinking_budget", None) == 2048 assert getattr(p, "_max_completion_tokens", None) == 10000 + def test_request_policy_override_wins_over_settings(self, monkeypatch): + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "dashscope") + monkeypatch.setattr( + "iac_code.config.get_provider_config", + lambda name: { + "effort": "low", + "thinkingBudget": 4096, + "maxCompletionTokens": 12288, + }, + ) + + p = create_provider( + "glm-5.2", + credentials={"dashscope": "key"}, + request_policy_override=ProviderRequestPolicy(thinking_enabled=False, effort="high", thinking_budget=2048), + ) + + assert getattr(p, "_thinking_enabled", None) is False + assert getattr(p, "_effort", None) == "high" + assert getattr(p, "_thinking_budget", None) == 2048 + assert getattr(p, "_max_completion_tokens", None) == 12288 + + def test_openai_compatible_qwen_request_policy_disabled_uses_model_thinking_wire_format(self, monkeypatch): + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai_compatible") + monkeypatch.setattr("iac_code.config.get_provider_config", lambda name: {}) + + p = create_provider( + "qwen3.6-plus", + credentials={"openai_compatible": "key"}, + request_policy_override=ProviderRequestPolicy(thinking_enabled=False), + ) + + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": False}} + def test_provider_request_policy_used_when_model_config_absent(self, monkeypatch): monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "dashscope") monkeypatch.setattr( @@ -145,6 +183,40 @@ def test_openai_compatible(self, monkeypatch): assert p.get_model_name() == "any-model" assert p._base_url == "https://my.llm.local/v1" + def test_openai_compatible_dashscope_base_uses_dashscope_default_thinking_wire_format(self, monkeypatch): + from iac_code.providers.dashscope_provider import DashScopeProvider + + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai_compatible") + monkeypatch.setattr( + "iac_code.config.get_provider_config", + lambda name: {"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + ) + + p = create_provider("glm-5.2", credentials={"openai_compatible": "sk-x"}) + + assert isinstance(p, DashScopeProvider) + assert getattr(p, "_PROVIDER_KEY", None) == "dashscope" + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": True, "thinking_budget": 8192}} + + def test_openai_compatible_dashscope_base_uses_dashscope_thinking_wire_format(self, monkeypatch): + from iac_code.providers.dashscope_provider import DashScopeProvider + + monkeypatch.setattr("iac_code.config.get_active_provider_key", lambda: "openai_compatible") + monkeypatch.setattr( + "iac_code.config.get_provider_config", + lambda name: {"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + ) + + p = create_provider( + "glm-5.2", + credentials={"openai_compatible": "sk-x"}, + request_policy_override=ProviderRequestPolicy(thinking_enabled=False, effort="low", thinking_budget=2048), + ) + + assert isinstance(p, DashScopeProvider) + assert getattr(p, "_PROVIDER_KEY", None) == "dashscope" + assert p._build_thinking_kwargs() == {"extra_body": {"enable_thinking": False}} + def test_dashscope_token_plan(self, monkeypatch): from iac_code.providers.dashscope_provider import ( DASHSCOPE_TOKEN_PLAN_BASE_URL, diff --git a/tests/providers/test_openai_provider.py b/tests/providers/test_openai_provider.py index bdf7925e..eed515f2 100644 --- a/tests/providers/test_openai_provider.py +++ b/tests/providers/test_openai_provider.py @@ -105,6 +105,21 @@ def test_no_effort_returns_empty(self): p = OpenAIProvider(model="gpt-5.5", api_key="k", effort=None) assert p._build_thinking_kwargs() == {} + def test_enabled_true_uses_default_effort(self): + from iac_code.providers.openai_provider import OpenAIProvider + + p = OpenAIProvider(model="gpt-5.5", api_key="k", thinking_enabled=True) + assert p._build_thinking_kwargs() == { + "reasoning_effort": "high", + "extra_body": {"thinking": {"type": "enabled"}}, + } + + def test_enabled_false_suppresses_effort(self): + from iac_code.providers.openai_provider import OpenAIProvider + + p = OpenAIProvider(model="gpt-5.5", api_key="k", effort="high", thinking_enabled=False) + assert p._build_thinking_kwargs() == {} + def test_auto_returns_empty(self): from iac_code.providers.openai_provider import OpenAIProvider diff --git a/tests/providers/test_provider_model_research_updates.py b/tests/providers/test_provider_model_research_updates.py index 07d2cf35..b7c47e8b 100644 --- a/tests/providers/test_provider_model_research_updates.py +++ b/tests/providers/test_provider_model_research_updates.py @@ -155,3 +155,32 @@ def test_provider_specific_thinking_wire_formats_do_not_use_openai_or_anthropic_ for kwargs in (kimi._build_thinking_kwargs(), zhipu._build_thinking_kwargs(), minimax._build_thinking_kwargs()): assert "reasoning_effort" not in kwargs assert kwargs.get("thinking", {}).get("budget_tokens") is None + + +def test_provider_specific_thinking_enabled_false_disables_native_wire_formats() -> None: + assert KimiProvider(model="kimi-k2.6", api_key="k", thinking_enabled=False)._build_thinking_kwargs() == { + "extra_body": {"thinking": {"type": "disabled"}} + } + assert ZhiPuProvider(model="glm-5.1", api_key="k", thinking_enabled=False)._build_thinking_kwargs() == { + "extra_body": {"thinking": {"type": "disabled"}} + } + assert MiniMaxProvider(model="MiniMax-M3", api_key="k", thinking_enabled=False)._build_thinking_kwargs() == { + "thinking": {"type": "disabled"} + } + + +def test_gemini_thinking_enabled_uses_default_effort_and_false_suppresses() -> None: + from iac_code.providers.gemini_provider import GeminiProvider + + assert GeminiProvider(model="gemini-2.5-pro", api_key="k", thinking_enabled=True)._build_thinking_kwargs() == { + "reasoning_effort": "medium" + } + assert ( + GeminiProvider( + model="gemini-2.5-pro", + api_key="k", + effort="high", + thinking_enabled=False, + )._build_thinking_kwargs() + == {} + ) diff --git a/tests/providers/test_request_logging.py b/tests/providers/test_request_logging.py new file mode 100644 index 00000000..4e672f7b --- /dev/null +++ b/tests/providers/test_request_logging.py @@ -0,0 +1,94 @@ +from iac_code.providers.request_logging import build_provider_request_policy_log + + +def test_provider_request_policy_log_keeps_only_safe_thinking_fields() -> None: + payload = build_provider_request_policy_log( + "dashscope", + "glm-5.2", + "chat.completions.stream", + { + "model": "glm-5.2", + "messages": [{"role": "user", "content": "secret prompt"}], + "system": "secret system prompt", + "tools": [{"function": {"description": "secret tool"}}], + "api_key": "sk-secret", + "stream": True, + "max_completion_tokens": 10240, + "reasoning_effort": "low", + "extra_body": { + "enable_thinking": True, + "thinking_budget": 2048, + "prompt_cache_key": "do-not-log", + "thinking": { + "type": "enabled", + "budget_tokens": 2048, + "text": "hidden reasoning", + }, + }, + }, + ) + + assert payload == { + "provider": "dashscope", + "model": "glm-5.2", + "operation": "chat.completions.stream", + "request": { + "stream": True, + "max_completion_tokens": 10240, + "reasoning_effort": "low", + "extra_body": { + "enable_thinking": True, + "thinking_budget": 2048, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + }, + }, + } + + +def test_provider_request_policy_log_sanitizes_anthropic_thinking() -> None: + payload = build_provider_request_policy_log( + "anthropic", + "claude-opus-4-7", + "messages.create", + { + "model": "claude-opus-4-7", + "system": "secret system prompt", + "messages": [{"role": "user", "content": "secret prompt"}], + "max_tokens": 20480, + "thinking": {"type": "enabled", "budget_tokens": 16384, "signature": "do-not-log"}, + }, + ) + + assert payload == { + "provider": "anthropic", + "model": "claude-opus-4-7", + "operation": "messages.create", + "request": { + "max_tokens": 20480, + "thinking": {"type": "enabled", "budget_tokens": 16384}, + }, + } + + +def test_provider_request_policy_log_keeps_disabled_flag_without_budget_or_effort() -> None: + payload = build_provider_request_policy_log( + "dashscope", + "glm-5.2", + "chat.completions.stream", + { + "model": "glm-5.2", + "messages": [{"role": "user", "content": "secret prompt"}], + "max_tokens": 8192, + "extra_body": {"enable_thinking": False}, + }, + ) + + assert payload == { + "provider": "dashscope", + "model": "glm-5.2", + "operation": "chat.completions.stream", + "request": { + "max_tokens": 8192, + "extra_body": {"enable_thinking": False}, + }, + } diff --git a/tests/services/permissions/test_audit.py b/tests/services/permissions/test_audit.py index 10a3bd90..61020874 100644 --- a/tests/services/permissions/test_audit.py +++ b/tests/services/permissions/test_audit.py @@ -19,12 +19,27 @@ sanitize_free_text, ) from iac_code.types.permissions import PermissionAuditMetadata, PermissionAuditSettings +from iac_code.utils.project_paths import sanitize_path def _read_jsonl(path: Path) -> list[dict]: return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] +def _session_audit_log_path(config_dir: Path, cwd: str, session_id: str) -> Path: + return config_dir / "projects" / sanitize_path(cwd) / session_id / "permission-audit.jsonl" + + +def _audit_log_path_for_record(config_dir: Path, record: PermissionAuditRecord) -> Path: + cwd = record.cwd.strip() or os.getcwd() + session_id = record.session_id.strip() or "unknown-session" + return _session_audit_log_path(config_dir, cwd, session_id) + + +def _read_audit_rows(config_dir: Path, record: PermissionAuditRecord) -> list[dict]: + return _read_jsonl(_audit_log_path_for_record(config_dir, record)) + + def _has_truncated_object(value: object) -> bool: if isinstance(value, dict): if value.get("type") == "object" and value.get("truncated") is True: @@ -150,7 +165,7 @@ def test_emit_permission_audit_preserves_field_shape_fingerprint_keys( assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) field_key = fingerprint_text("StackName") assert row["input_summary"]["params_fields"] == [field_key] assert field_key in row["input_summary"]["params_field_shapes"] @@ -178,7 +193,7 @@ def test_emit_permission_audit_sanitizes_raw_field_shape_keys( assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) field_key = fingerprint_text("StackName") serialized = json.dumps(row) assert "StackName" not in serialized @@ -312,7 +327,7 @@ def test_emit_permission_audit_truncates_deep_input_summary_before_serializing( assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024 * 1024, max_files=2)) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) assert _has_truncated_object(row["input_summary"]) @@ -411,8 +426,10 @@ def test_emit_permission_audit_writes_jsonl_and_telemetry(tmp_path: Path, monkey monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) telemetry = Mock() monkeypatch.setattr("iac_code.services.permissions.audit.log_event", telemetry) + cwd = "/home/workspace/context-1" record = PermissionAuditRecord( session_id="s1", + cwd=cwd, tool_name="aliyun_api", tool_use_id="tu1", decision="allow", @@ -427,10 +444,11 @@ def test_emit_permission_audit_writes_jsonl_and_telemetry(tmp_path: Path, monkey emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - log_path = tmp_path / "logs" / "permission-audit.jsonl" + log_path = _session_audit_log_path(tmp_path, cwd, "s1") rows = _read_jsonl(log_path) assert rows[0]["decision"] == "allow" assert rows[0]["rule_source"] == "user_settings" + assert not (tmp_path / "logs" / "permission-audit.jsonl").exists() telemetry.assert_called_once() assert telemetry.call_args.args[0] == "iac.tool.permission.granted" @@ -491,7 +509,10 @@ def test_emit_permission_audit_clamps_excessive_max_files_before_rotation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: tmp_path / "permission-audit.jsonl") + monkeypatch.setattr( + "iac_code.services.permissions.audit._log_path", + lambda _record: tmp_path / "permission-audit.jsonl", + ) monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) captured: dict[str, int] = {} @@ -532,16 +553,51 @@ def test_emit_permission_audit_restricts_jsonl_file_permissions(tmp_path: Path, emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) if os.name != "nt": - mode = stat.S_IMODE((tmp_path / "logs" / "permission-audit.jsonl").stat().st_mode) + mode = stat.S_IMODE(_audit_log_path_for_record(tmp_path, record).stat().st_mode) assert mode == 0o600 @pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not meaningful on Windows") -def test_emit_permission_audit_fails_when_jsonl_permissions_remain_too_broad( +def test_emit_permission_audit_allows_append_when_chmod_cannot_restrict_session_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("IAC_CODE_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) + monkeypatch.setattr("iac_code.services.permissions.audit.ensure_private_file", lambda path: path) + cwd = "/home/workspace/context-chmod" + + def fake_append(path, records, **kwargs): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(list(records)[0]) + "\n", encoding="utf-8") + path.chmod(0o666) + + monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", fake_append) + record = PermissionAuditRecord( + session_id="s-broad-session", + cwd=cwd, + tool_name="bash", + tool_use_id="tu-broad-session", + decision="allow", + scope="settings_rule", + source="permission_pipeline", + ) + + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is True + [row] = _read_jsonl(_session_audit_log_path(tmp_path, cwd, "s-broad-session")) + assert row["decision"] == "allow" + assert not (tmp_path / "logs" / "permission-audit.jsonl").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not meaningful on Windows") +def test_emit_permission_audit_succeeds_when_jsonl_permissions_remain_too_broad( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: tmp_path / "permission-audit.jsonl") + monkeypatch.setattr( + "iac_code.services.permissions.audit._log_path", + lambda _record: tmp_path / "permission-audit.jsonl", + ) monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) monkeypatch.setattr("iac_code.services.permissions.audit.ensure_private_file", lambda path: path) @@ -559,21 +615,26 @@ def fake_append(path, records, **kwargs): source="permission_pipeline", ) - assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is True @pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not meaningful on Windows") -def test_emit_permission_audit_refuses_existing_broad_jsonl_before_append( +def test_emit_permission_audit_appends_existing_broad_jsonl( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: log_path = tmp_path / "permission-audit.jsonl" log_path.write_text("", encoding="utf-8") log_path.chmod(0o666) - monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda: log_path) + monkeypatch.setattr("iac_code.services.permissions.audit._log_path", lambda _record: log_path) monkeypatch.setattr("iac_code.services.permissions.audit.log_event", Mock()) monkeypatch.setattr("iac_code.services.permissions.audit.ensure_private_file", lambda path: path) - append = Mock() + append = Mock( + side_effect=lambda path, records, **kwargs: path.write_text( + json.dumps(list(records)[0]) + "\n", + encoding="utf-8", + ) + ) monkeypatch.setattr("iac_code.services.permissions.audit.append_jsonl_rotating_locked", append) record = PermissionAuditRecord( session_id="s-existing-broad", @@ -584,8 +645,8 @@ def test_emit_permission_audit_refuses_existing_broad_jsonl_before_append( source="permission_pipeline", ) - assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is False - append.assert_not_called() + assert emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) is True + append.assert_called_once() def test_emit_permission_audit_omits_unsafe_rule_text_from_jsonl(tmp_path: Path, monkeypatch) -> None: @@ -608,7 +669,7 @@ def test_emit_permission_audit_omits_unsafe_rule_text_from_jsonl(tmp_path: Path, emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) assert row["reason_detail"] == "matched permission rule" assert "rule" not in row assert row["rule_fingerprint"] == fingerprint_text("bash(echo secret-value)") @@ -636,7 +697,7 @@ def test_emit_permission_audit_preserves_safe_multi_rule_text(tmp_path: Path, mo emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) assert row["rule"] == rule assert row["rule_fingerprint"] == fingerprint_text(rule) @@ -658,7 +719,7 @@ def test_emit_permission_audit_excludes_tool_input_redacted_by_default(tmp_path: emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + rows = _read_audit_rows(tmp_path, record) assert "tool_input_redacted" not in rows[0] assert "secret-value" not in json.dumps(rows[0]) @@ -728,7 +789,7 @@ def test_emit_permission_audit_truncates_redacted_tool_input_when_enabled( settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024 * 1024, max_files=2), ) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) nested_key = fingerprint_text("nested") wide_key = fingerprint_text("wide") assert _has_truncated_object(row["tool_input_redacted"][nested_key]) @@ -770,7 +831,7 @@ def test_emit_permission_audit_includes_redacted_tool_input_when_enabled(tmp_pat settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), ) - rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + rows = _read_audit_rows(tmp_path, record) assert rows[0]["tool_input_redacted"] == { fingerprint_text("accessKeyId"): {"redacted": True}, fingerprint_text("apiKey"): {"redacted": True}, @@ -831,7 +892,7 @@ def test_emit_permission_audit_redacts_embedded_json_and_pem_strings(tmp_path: P settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), ) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) rendered = json.dumps(row, ensure_ascii=False) assert "super-secret" not in rendered assert "private-body" not in rendered @@ -870,7 +931,7 @@ def test_emit_permission_audit_tool_input_redaction_omits_business_payloads(tmp_ settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), ) - [row] = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + [row] = _read_audit_rows(tmp_path, record) serialized = json.dumps(row, ensure_ascii=False) assert row["tool_input_redacted"]["product"] == { "type": "str", @@ -919,7 +980,7 @@ def test_emit_permission_audit_uses_minimal_private_telemetry_metadata(tmp_path: settings=PermissionAuditSettings(include_tool_input=True, max_file_bytes=1024, max_files=2), ) - rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + rows = _read_audit_rows(tmp_path, record) assert rows[0]["reason_detail"] == "matched permission rule" assert rows[0]["tool_input_redacted"] == {"params": {fingerprint_text("StackName"): {"redacted": True}}} assert rows[0]["operation"] == { @@ -1017,6 +1078,6 @@ def test_emit_permission_audit_telemetry_failure_does_not_prevent_jsonl(tmp_path emit_permission_audit(record, settings=PermissionAuditSettings(max_file_bytes=1024, max_files=2)) - rows = _read_jsonl(tmp_path / "logs" / "permission-audit.jsonl") + rows = _read_audit_rows(tmp_path, record) assert rows[0]["decision"] == "allow" telemetry.assert_called_once() diff --git a/tests/ui/test_repl_shell_escape.py b/tests/ui/test_repl_shell_escape.py index 674379dc..e057d9d2 100644 --- a/tests/ui/test_repl_shell_escape.py +++ b/tests/ui/test_repl_shell_escape.py @@ -246,6 +246,7 @@ async def test_shell_escape_permission_allow_is_audited_without_raw_command(tmp_ assert record.source == "permission_pipeline" assert record.scope == "settings_rule" assert record.decision == "allow" + assert record.cwd == str(tmp_path) assert record.operation == {"is_read_only": False} assert "echo secret-value" not in str(record.input_summary) @@ -307,6 +308,7 @@ async def test_shell_escape_permission_denial_is_audited(tmp_path, monkeypatch): assert record.source == "permission_pipeline" assert record.scope == "settings_rule" assert record.decision == "deny" + assert record.cwd == str(tmp_path) assert record.tool_name == "bash" @@ -369,6 +371,7 @@ async def fake_execute_batch(self, calls, context): assert len(audit_records) == 1 record = audit_records[0] assert record.decision == "allow" + assert record.cwd == str(tmp_path) assert record.scope == "session_rule" assert record.source == "permission_pipeline" assert record.rule_source == "session" @@ -402,6 +405,7 @@ async def test_shell_escape_bash_internal_deny_rule_emits_audit(tmp_path, monkey assert len(audit_records) == 1 record = audit_records[0] assert record.decision == "deny" + assert record.cwd == str(tmp_path) assert record.scope == "session_rule" assert record.source == "permission_pipeline" assert record.rule_source == "session" @@ -440,6 +444,7 @@ async def test_shell_escape_permission_prompt_rejection_does_not_execute(tmp_pat assert [event.tool_input for event in repl.renderer.permission_events] == [{"command": "mkdir maybe"}] assert repl.renderer.permission_events[0].audit_context == { "session_id": "", + "cwd": str(tmp_path), "settings": settings, "metadata": metadata, } diff --git a/website/docs/a2a/command-reference.md b/website/docs/a2a/command-reference.md index ec3618bf..a2c98962 100644 --- a/website/docs/a2a/command-reference.md +++ b/website/docs/a2a/command-reference.md @@ -286,6 +286,9 @@ iac-code a2a-client --config a2a-client.yml call \ | `--context-id` | empty | Existing A2A context ID for a follow-up message | | `--iac-code-model` | empty | LLM model sent as `message.metadata.iac_code.iac_code_model`; overrides server model config for this message turn only | | `--iac-code-api-key` | empty | LLM provider API key sent as `message.metadata.iac_code.iac_code_api_key`; overrides `IAC_CODE_API_KEY` and `.credentials.yml` for this message turn only | +| `--thinking-enabled`, `--no-thinking-enabled` | empty | Boolean thinking policy sent as `message.metadata.iac_code.thinking.enabled`; omitted preserves the server/provider default | +| `--thinking-effort` | empty | Thinking effort sent as `message.metadata.iac_code.thinking.effort` for this message turn only | +| `--thinking-budget` | empty | Positive integer thinking budget sent as `message.metadata.iac_code.thinking.budget` for this message turn only | | `--verify-card-secret`, `--signing-secret` | empty | HMAC secret for Agent Card verification | | `--verify-card-jwks-url` | empty | Remote JWKS URL used for Agent Card verification | | `--require-card-signature`, `--require-signature` | `false` | Reject unsigned or invalid Agent Cards | @@ -294,6 +297,14 @@ iac-code a2a-client --config a2a-client.yml call \ `--iac-code-api-key` is the key used by the remote iac-code runtime to call its LLM provider. It is separate from `--api-key`, which authenticates the A2A HTTP request itself. +Thinking options are per-message request metadata. They can also be supplied in the A2A client YAML as `thinking-enabled`, `thinking-effort`, and `thinking-budget`; command-line flags override config values. If all three are omitted, the client sends no thinking metadata and the remote runtime keeps its configured provider defaults. For `openai_compatible` endpoints backed by DashScope compatible-mode, an explicit thinking policy uses DashScope's native wire parameters so `--no-thinking-enabled` can send `extra_body.enable_thinking=false`. Emitting raw thinking back to the A2A client still requires the server to enable `thinking-exposure: raw-thinking`. + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + Follow-up in the same context: ```bash diff --git a/website/docs/a2a/getting-started.md b/website/docs/a2a/getting-started.md index 80ceb486..c70c9d78 100644 --- a/website/docs/a2a/getting-started.md +++ b/website/docs/a2a/getting-started.md @@ -141,6 +141,10 @@ require-card-signature: true cwd: /path/to/workspace iac-code-model: qwen-plus iac-code-api-key: provider-api-key +# Optional per-message thinking policy metadata: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` Use `a2a-client call` for a direct Phase 1 client call: diff --git a/website/docs/configuration/runtime-configuration.md b/website/docs/configuration/runtime-configuration.md index 478b0be7..fbf68789 100644 --- a/website/docs/configuration/runtime-configuration.md +++ b/website/docs/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | Field | Scope | Description | |---|---|---| +| `thinkingEnabled` | Provider or model | Optional boolean thinking switch. `true` asks supported providers/models to enable thinking; `false` asks them to disable it; omitted preserves the provider/model default. | | `thinkingBudget` | Provider or model | Positive integer reasoning/thinking budget passed to providers that support it. | | `maxCompletionTokens` | Provider or model | Positive integer `max_completion_tokens` override for providers/models that use that request field. | | `effort` | Provider or model | Optional thinking effort override for models that support effort control. | @@ -90,6 +93,8 @@ Model-level values under `providers..models.` override provider For Alibaba Cloud DashScope and DashScope Token Plan, IaC Code has a built-in `thinkingBudget` of `8192` for `glm-5.2` and `kimi-k2.7-code`. When `maxCompletionTokens` is not set, the request limit is computed as the normal answer token limit plus the effective thinking budget. +A2A requests may override these settings for a single message turn through `message.metadata.iac_code.thinking` or the `iac-code a2a-client call` flags `--thinking-enabled`, `--thinking-effort`, and `--thinking-budget`. If no explicit A2A thinking metadata is sent, the runtime uses the settings above and the provider's normal defaults. For generic `openai_compatible` providers with a DashScope compatible-mode base URL, iac-code switches to the DashScope native thinking wire format only when an explicit thinking policy is present. + ## Tool Permission Configuration The `permissions` section in `settings.yml` configures which tool actions are allowed, denied, or require confirmation: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current.json b/website/i18n/de/docusaurus-plugin-content-docs/current.json index 4d575498..1b38f3ad 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/de/docusaurus-plugin-content-docs/current.json @@ -23,6 +23,10 @@ "message": "ACP-Protokoll", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "A2A-Protokoll", + "description": "The label for category 'A2A 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/a2a/command-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 5b41bd01..799411b8 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -278,12 +278,27 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | erforderlich | Prompt-Text | | `--cwd` | `.` | Workspace-Pfad, gesendet als `message.metadata.iac_code.cwd` | | `--context-id` | leer | Vorhandene A2A-Kontext-ID fuer eine Follow-up-Nachricht | +| `--iac-code-model` | leer | LLM-Modell, gesendet als `message.metadata.iac_code.iac_code_model`; überschreibt die Server-Modellkonfiguration nur für diesen Message-Turn | +| `--iac-code-api-key` | leer | LLM-Provider-API-Key, gesendet als `message.metadata.iac_code.iac_code_api_key`; überschreibt `IAC_CODE_API_KEY` und `.credentials.yml` nur für diesen Message-Turn | +| `--thinking-enabled`, `--no-thinking-enabled` | leer | Boolesche Thinking-Richtlinie, gesendet als `message.metadata.iac_code.thinking.enabled`; ausgelassen bleibt der Server-/Provider-Default erhalten | +| `--thinking-effort` | leer | Thinking-Effort, gesendet als `message.metadata.iac_code.thinking.effort`, nur für diesen Message-Turn | +| `--thinking-budget` | leer | Positives ganzzahliges Thinking-Budget, gesendet als `message.metadata.iac_code.thinking.budget`, nur für diesen Message-Turn | | `--verify-card-secret`, `--signing-secret` | leer | HMAC-Secret fuer Agent-Card-Verifikation | | `--verify-card-jwks-url` | leer | Entfernte JWKS-URL fuer Agent-Card-Verifikation | | `--require-card-signature`, `--require-signature` | `false` | Unsignierte oder ungueltige Agent Cards ablehnen | | `--timeout` | `30.0` | Aufruf-Timeout in Sekunden | | `--stream` | `false` | `SendStreamingMessage` verwenden und Stream-Events ausgeben | +`--iac-code-api-key` ist der Key, mit dem der entfernte iac-code-Runtime seinen LLM-Provider aufruft. Er ist getrennt von `--api-key`, das die A2A-HTTP-Anfrage selbst authentifiziert. + +Thinking-Optionen sind Request-Metadata pro Message. Sie können im A2A-Client-YAML auch als `thinking-enabled`, `thinking-effort` und `thinking-budget` angegeben werden; Kommandozeilen-Flags überschreiben Konfigurationswerte. Wenn alle drei ausgelassen werden, sendet der Client keine Thinking-Metadata und der entfernte Runtime behält seine konfigurierten Provider-Defaults. Für `openai_compatible`-Endpoints mit DashScope compatible-mode im Hintergrund verwendet eine explizite Thinking-Richtlinie DashScopes native Wire-Parameter, sodass `--no-thinking-enabled` `extra_body.enable_thinking=false` senden kann. Raw Thinking an den A2A-Client auszugeben erfordert weiterhin, dass der Server `thinking-exposure: raw-thinking` aktiviert. + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + Follow-up im selben Kontext: ```bash diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md index f4097356..d160e3c4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,12 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus +iac-code-api-key: provider-api-key +# Optional: Thinking-Richtlinien-Metadata pro Message: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` Verwenden Sie `a2a-client call` fuer einen direkten Phase-1-Clientaufruf: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 91e5886e..0e089fc6 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | Feld | Geltungsbereich | Beschreibung | |---|---|---| +| `thinkingEnabled` | Provider oder Modell | Optionaler boolescher Thinking-Schalter. `true` fordert unterstützte Provider/Modelle auf, Thinking zu aktivieren; `false` fordert die Deaktivierung an; ausgelassen bleibt der Provider-/Model-Default erhalten. | | `thinkingBudget` | Provider oder Modell | Positives ganzzahliges Budget für Reasoning/Thinking, das an Provider übergeben wird, die es unterstützen. | | `maxCompletionTokens` | Provider oder Modell | Positiver ganzzahliger Überschreibungswert für `max_completion_tokens` bei Providern/Modellen, die dieses Anfragefeld verwenden. | | `effort` | Provider oder Modell | Optionale Überschreibung des Thinking-Aufwands; nur wirksam bei Modellen, die Effort-Steuerung unterstützen. | @@ -90,6 +93,8 @@ Gültige modellbezogene Werte unter `providers..models.` übers Für Alibaba Cloud DashScope und DashScope Token Plan hat IaC Code ein eingebautes `thinkingBudget=8192` für `glm-5.2` und `kimi-k2.7-code`. Wenn `maxCompletionTokens` nicht gesetzt ist, wird das Anfrage-Limit aus dem normalen Antwort-Token-Limit plus dem wirksamen Thinking Budget berechnet. +A2A-Anfragen können diese Einstellungen für einen einzelnen Message-Turn über `message.metadata.iac_code.thinking` oder die Flags `--thinking-enabled`, `--thinking-effort` und `--thinking-budget` von `iac-code a2a-client call` überschreiben. Wenn keine expliziten A2A-Thinking-Metadata gesendet werden, verwendet der Runtime die obigen Einstellungen und die normalen Defaults des Providers. Für generische `openai_compatible`-Provider mit einer DashScope-compatible-mode-Base-URL wechselt iac-code nur dann zum nativen DashScope-Thinking-Wire-Format, wenn eine explizite Thinking-Richtlinie vorhanden ist. + ## Werkzeug-Berechtigungskonfiguration Der Abschnitt `permissions` in `settings.yml` konfiguriert, welche Werkzeugaktionen erlaubt, verweigert oder bestätigt werden müssen: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current.json b/website/i18n/es/docusaurus-plugin-content-docs/current.json index b24b182f..1ea7d0b6 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/es/docusaurus-plugin-content-docs/current.json @@ -23,6 +23,10 @@ "message": "Protocolo ACP", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "Protocolo A2A", + "description": "The label for category 'A2A 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/a2a/command-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 98845efa..f86a0d54 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -278,12 +278,27 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | requerido | Texto del prompt | | `--cwd` | `.` | Ruta de espacio de trabajo enviada como `message.metadata.iac_code.cwd` | | `--context-id` | vacío | ID de contexto A2A existente para un mensaje de seguimiento | +| `--iac-code-model` | vacío | Modelo LLM enviado como `message.metadata.iac_code.iac_code_model`; anula la configuración del modelo del servidor solo para este turno de mensaje | +| `--iac-code-api-key` | vacío | API key del proveedor LLM enviada como `message.metadata.iac_code.iac_code_api_key`; anula `IAC_CODE_API_KEY` y `.credentials.yml` solo para este turno de mensaje | +| `--thinking-enabled`, `--no-thinking-enabled` | vacío | Política booleana de thinking enviada como `message.metadata.iac_code.thinking.enabled`; si se omite, conserva el valor predeterminado del servidor/proveedor | +| `--thinking-effort` | vacío | Effort de thinking enviado como `message.metadata.iac_code.thinking.effort` solo para este turno de mensaje | +| `--thinking-budget` | vacío | Presupuesto de thinking como entero positivo enviado como `message.metadata.iac_code.thinking.budget` solo para este turno de mensaje | | `--verify-card-secret`, `--signing-secret` | vacío | Secreto HMAC para verificación de Agent Card | | `--verify-card-jwks-url` | vacío | URL JWKS remota usada para verificación de Agent Card | | `--require-card-signature`, `--require-signature` | `false` | Rechazar Agent Cards sin firmar o inválidas | | `--timeout` | `30.0` | Timeout de llamada en segundos | | `--stream` | `false` | Usar `SendStreamingMessage` e imprimir eventos de stream | +`--iac-code-api-key` es la clave que usa el runtime iac-code remoto para llamar a su proveedor LLM. Es distinta de `--api-key`, que autentica la solicitud HTTP A2A en sí. + +Las opciones de thinking son metadatos de solicitud por mensaje. También se pueden proporcionar en el YAML del cliente A2A como `thinking-enabled`, `thinking-effort` y `thinking-budget`; los flags de línea de comandos anulan los valores de configuración. Si se omiten las tres, el cliente no envía metadatos de thinking y el runtime remoto conserva los valores predeterminados configurados del proveedor. Para endpoints `openai_compatible` respaldados por el modo compatible de DashScope, una política de thinking explícita usa los parámetros wire nativos de DashScope, por lo que `--no-thinking-enabled` puede enviar `extra_body.enable_thinking=false`. Emitir raw thinking de vuelta al cliente A2A sigue requiriendo que el servidor habilite `thinking-exposure: raw-thinking`. + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + Seguimiento en el mismo contexto: ```bash diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md index fe28c7ae..04f06dae 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,12 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus +iac-code-api-key: provider-api-key +# Opcional: metadatos de política de thinking por mensaje: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` Usa `a2a-client call` para una llamada directa de cliente de Fase 1: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 11358ad3..7b71adbd 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | Campo | Alcance | Descripción | |---|---|---| +| `thinkingEnabled` | Proveedor o modelo | Interruptor booleano opcional de thinking. `true` pide a proveedores/modelos compatibles que lo habiliten; `false` pide deshabilitarlo; si se omite, conserva el valor predeterminado del proveedor/modelo. | | `thinkingBudget` | Proveedor o modelo | Presupuesto de reasoning/thinking como entero positivo, enviado a los proveedores que lo admiten. | | `maxCompletionTokens` | Proveedor o modelo | Valor entero positivo que anula `max_completion_tokens` para proveedores/modelos que usan ese campo de solicitud. | | `effort` | Proveedor o modelo | Anulación opcional del effort de thinking, solo para modelos que admiten control de effort. | @@ -90,6 +93,8 @@ Los valores válidos a nivel de modelo bajo `providers..models. Para Alibaba Cloud DashScope y DashScope Token Plan, IaC Code incluye un `thinkingBudget=8192` integrado para `glm-5.2` y `kimi-k2.7-code`. Si `maxCompletionTokens` no está configurado, el límite de la solicitud se calcula como el límite normal de tokens de respuesta más el thinking budget efectivo. +Las solicitudes A2A pueden anular estos ajustes para un solo turno de mensaje mediante `message.metadata.iac_code.thinking` o los flags `--thinking-enabled`, `--thinking-effort` y `--thinking-budget` de `iac-code a2a-client call`. Si no se envían metadatos de thinking A2A explícitos, el runtime usa los ajustes anteriores y los valores predeterminados normales del proveedor. Para proveedores genéricos `openai_compatible` con una base URL en modo compatible de DashScope, iac-code cambia al formato wire nativo de thinking de DashScope solo cuando hay una política de thinking explícita. + ## Configuración de permisos de herramientas La sección `permissions` en `settings.yml` configura qué acciones de herramientas se permiten, deniegan o requieren confirmación: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current.json b/website/i18n/fr/docusaurus-plugin-content-docs/current.json index 866ce4ed..5958d070 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current.json @@ -23,6 +23,10 @@ "message": "Protocole ACP", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "Protocole A2A", + "description": "The label for category 'A2A 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/a2a/command-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 88f026bb..a66e6054 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -278,12 +278,27 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | obligatoire | Texte du prompt | | `--cwd` | `.` | Chemin d'espace de travail envoyé comme `message.metadata.iac_code.cwd` | | `--context-id` | vide | ID de contexte A2A existant pour un message de suivi | +| `--iac-code-model` | vide | Modèle LLM envoyé comme `message.metadata.iac_code.iac_code_model` ; remplace la configuration de modèle du serveur pour ce tour de message uniquement | +| `--iac-code-api-key` | vide | Clé API du provider LLM envoyée comme `message.metadata.iac_code.iac_code_api_key` ; remplace `IAC_CODE_API_KEY` et `.credentials.yml` pour ce tour de message uniquement | +| `--thinking-enabled`, `--no-thinking-enabled` | vide | Politique booléenne de thinking envoyée comme `message.metadata.iac_code.thinking.enabled` ; si elle est omise, le défaut serveur/provider est conservé | +| `--thinking-effort` | vide | Effort de thinking envoyé comme `message.metadata.iac_code.thinking.effort` pour ce tour de message uniquement | +| `--thinking-budget` | vide | Budget de thinking entier positif envoyé comme `message.metadata.iac_code.thinking.budget` pour ce tour de message uniquement | | `--verify-card-secret`, `--signing-secret` | vide | Secret HMAC pour la vérification de l'Agent Card | | `--verify-card-jwks-url` | vide | URL JWKS distante utilisée pour la vérification de l'Agent Card | | `--require-card-signature`, `--require-signature` | `false` | Rejeter les Agent Cards non signées ou invalides | | `--timeout` | `30.0` | Délai d'appel en secondes | | `--stream` | `false` | Utiliser `SendStreamingMessage` et afficher les événements du flux | +`--iac-code-api-key` est la clé utilisée par le runtime iac-code distant pour appeler son provider LLM. Elle est distincte de `--api-key`, qui authentifie la requête HTTP A2A elle-même. + +Les options de thinking sont des métadonnées de requête par message. Elles peuvent aussi être fournies dans le YAML du client A2A sous les clés `thinking-enabled`, `thinking-effort` et `thinking-budget` ; les flags de ligne de commande remplacent les valeurs de configuration. Si les trois sont omises, le client n'envoie aucune métadonnée de thinking et le runtime distant conserve les valeurs par défaut configurées du provider. Pour les endpoints `openai_compatible` adossés au mode compatible DashScope, une politique de thinking explicite utilise les paramètres wire natifs de DashScope, de sorte que `--no-thinking-enabled` peut envoyer `extra_body.enable_thinking=false`. Le renvoi du raw thinking au client A2A nécessite toujours que le serveur active `thinking-exposure: raw-thinking`. + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + Suivi dans le même contexte : ```bash diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md index 929d141f..e2e340a9 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,12 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus +iac-code-api-key: provider-api-key +# Optionnel : métadonnées de politique de thinking par message : +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` Utilisez `a2a-client call` pour un appel client Phase 1 direct : diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 44d532b8..f71f8b4d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | Champ | Portée | Description | |---|---|---| +| `thinkingEnabled` | Fournisseur ou modèle | Interrupteur booléen optionnel pour le thinking. `true` demande aux fournisseurs/modèles compatibles de l'activer ; `false` demande de le désactiver ; si le champ est omis, le défaut du fournisseur/modèle est conservé. | | `thinkingBudget` | Fournisseur ou modèle | Budget de reasoning/thinking sous forme d'entier positif, transmis aux fournisseurs qui le prennent en charge. | | `maxCompletionTokens` | Fournisseur ou modèle | Valeur positive entière qui remplace `max_completion_tokens` pour les fournisseurs/modèles utilisant ce champ de requête. | | `effort` | Fournisseur ou modèle | Surcharge optionnelle de l'effort de thinking, uniquement pour les modèles qui prennent en charge le contrôle d'effort. | @@ -90,6 +93,8 @@ Les valeurs valides définies au niveau du modèle sous `providers..mo Pour Alibaba Cloud DashScope et DashScope Token Plan, IaC Code définit une valeur intégrée `thinkingBudget=8192` pour `glm-5.2` et `kimi-k2.7-code`. Si `maxCompletionTokens` n'est pas défini, la limite de requête est calculée comme la limite normale de tokens de réponse plus le thinking budget effectif. +Les requêtes A2A peuvent remplacer ces réglages pour un seul tour de message via `message.metadata.iac_code.thinking` ou les flags `--thinking-enabled`, `--thinking-effort` et `--thinking-budget` de `iac-code a2a-client call`. Si aucune métadonnée de thinking A2A explicite n'est envoyée, le runtime utilise les réglages ci-dessus et les valeurs par défaut normales du provider. Pour les providers génériques `openai_compatible` avec une base URL en mode compatible DashScope, iac-code ne bascule vers le format wire natif de thinking DashScope que lorsqu'une politique de thinking explicite est présente. + ## Configuration des permissions d'outils La section `permissions` dans `settings.yml` configure quelles actions d'outils sont autorisées, refusées ou nécessitent une confirmation : diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current.json b/website/i18n/ja/docusaurus-plugin-content-docs/current.json index b94e9bcd..9fb6f6dc 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current.json @@ -23,6 +23,10 @@ "message": "ACP プロトコル", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "A2A プロトコル", + "description": "The label for category 'A2A 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/a2a/command-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 15ef0d74..4c20ab14 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -278,12 +278,27 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | required | プロンプトテキスト | | `--cwd` | `.` | `message.metadata.iac_code.cwd` として送信されるワークスペースパス | | `--context-id` | empty | フォローアップメッセージ用の既存 A2A context ID | +| `--iac-code-model` | empty | `message.metadata.iac_code.iac_code_model` として送信される LLM model。この message turn のみ server model 設定を上書きします | +| `--iac-code-api-key` | empty | `message.metadata.iac_code.iac_code_api_key` として送信される LLM provider API key。この message turn のみ `IAC_CODE_API_KEY` と `.credentials.yml` を上書きします | +| `--thinking-enabled`, `--no-thinking-enabled` | empty | `message.metadata.iac_code.thinking.enabled` として送信される boolean thinking ポリシー。省略時は server/provider のデフォルトを維持します | +| `--thinking-effort` | empty | この message turn のみ `message.metadata.iac_code.thinking.effort` として送信される thinking effort | +| `--thinking-budget` | empty | この message turn のみ `message.metadata.iac_code.thinking.budget` として送信される正の整数 thinking budget | | `--verify-card-secret`, `--signing-secret` | empty | Agent Card 検証用の HMAC secret | | `--verify-card-jwks-url` | empty | Agent Card 検証に使用されるリモート JWKS URL | | `--require-card-signature`, `--require-signature` | `false` | 署名なしまたは無効な Agent Card を拒否 | | `--timeout` | `30.0` | 呼び出しタイムアウト秒数 | | `--stream` | `false` | `SendStreamingMessage` を使用し、ストリームイベントを出力 | +`--iac-code-api-key` は、リモート iac-code runtime が LLM provider を呼び出すために使用する key です。A2A HTTP リクエスト自体を認証する `--api-key` とは別のものです。 + +Thinking オプションは message 単位の request metadata です。A2A client YAML では `thinking-enabled`、`thinking-effort`、`thinking-budget` として指定することもでき、コマンドラインフラグは設定値を上書きします。3 つすべてを省略すると、client は thinking metadata を送信せず、リモート runtime は設定済み provider default を維持します。DashScope compatible-mode を背後に持つ `openai_compatible` endpoint では、明示的な thinking ポリシーが DashScope のネイティブ wire parameter を使用するため、`--no-thinking-enabled` は `extra_body.enable_thinking=false` を送信できます。A2A client に raw thinking を返すには、server 側で引き続き `thinking-exposure: raw-thinking` を有効にする必要があります。 + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + 同じコンテキスト内のフォローアップ: ```bash diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md index 0635944b..0abec486 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,12 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus +iac-code-api-key: provider-api-key +# 任意: message 単位の thinking ポリシー metadata: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` 直接の Phase 1 クライアント呼び出しには `a2a-client call` を使用します。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 64cdcbf0..3f29510f 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | フィールド | スコープ | 説明 | |---|---|---| +| `thinkingEnabled` | Provider またはモデル | 任意の boolean thinking スイッチ。`true` は対応する provider/model に thinking の有効化を要求し、`false` は無効化を要求します。省略時は provider/model のデフォルトを維持します。 | | `thinkingBudget` | Provider またはモデル | 正の整数の reasoning/thinking 予算。対応している provider に渡されます。 | | `maxCompletionTokens` | Provider またはモデル | `max_completion_tokens` を使う provider/model 向けの、正の整数の上書き値。 | | `effort` | Provider またはモデル | thinking effort の任意の上書き値。effort 制御に対応したモデルでのみ有効です。 | @@ -90,6 +93,8 @@ providers: Alibaba Cloud DashScope と DashScope Token Plan では、IaC Code は `glm-5.2` と `kimi-k2.7-code` に組み込みで `thinkingBudget=8192` を設定しています。`maxCompletionTokens` が未設定の場合、リクエスト上限は通常の回答 token 上限に有効な thinking budget を加えて計算されます。 +A2A リクエストは、`message.metadata.iac_code.thinking` または `iac-code a2a-client call` の `--thinking-enabled`、`--thinking-effort`、`--thinking-budget` フラグを通じて、この設定を 1 回の message turn だけ上書きできます。明示的な A2A thinking metadata が送信されない場合、runtime は上記の設定と provider の通常のデフォルトを使用します。DashScope compatible-mode の base URL を持つ汎用 `openai_compatible` provider では、明示的な thinking ポリシーがある場合にのみ、iac-code は DashScope ネイティブの thinking wire format に切り替えます。 + ## ツール権限設定 `settings.yml` の `permissions` セクションで、どのツールアクションを許可、拒否、または確認を必要とするかを設定します: diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current.json b/website/i18n/pt/docusaurus-plugin-content-docs/current.json index 45770e4b..490d12e7 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current.json @@ -23,6 +23,10 @@ "message": "Protocolo ACP", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "Protocolo A2A", + "description": "The label for category 'A2A 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/a2a/command-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md index 7e6fc366..f1e20c4a 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -278,12 +278,27 @@ iac-code a2a-client --config a2a-client.yml call \ | `--prompt`, `-p` | obrigatório | Texto do prompt | | `--cwd` | `.` | Caminho do workspace enviado como `message.metadata.iac_code.cwd` | | `--context-id` | vazio | ID de contexto A2A existente para uma mensagem de acompanhamento | +| `--iac-code-model` | vazio | Modelo LLM enviado como `message.metadata.iac_code.iac_code_model`; substitui a configuração de modelo do servidor somente neste turno de mensagem | +| `--iac-code-api-key` | vazio | API key do provedor LLM enviada como `message.metadata.iac_code.iac_code_api_key`; substitui `IAC_CODE_API_KEY` e `.credentials.yml` somente neste turno de mensagem | +| `--thinking-enabled`, `--no-thinking-enabled` | vazio | Política booleana de thinking enviada como `message.metadata.iac_code.thinking.enabled`; se omitida, preserva o padrão do servidor/provedor | +| `--thinking-effort` | vazio | Effort de thinking enviado como `message.metadata.iac_code.thinking.effort` somente neste turno de mensagem | +| `--thinking-budget` | vazio | Budget de thinking como inteiro positivo enviado como `message.metadata.iac_code.thinking.budget` somente neste turno de mensagem | | `--verify-card-secret`, `--signing-secret` | vazio | Segredo HMAC para verificação do Agent Card | | `--verify-card-jwks-url` | vazio | URL JWKS remota usada para verificação do Agent Card | | `--require-card-signature`, `--require-signature` | `false` | Rejeitar Agent Cards não assinados ou inválidos | | `--timeout` | `30.0` | Timeout da chamada em segundos | | `--stream` | `false` | Usar `SendStreamingMessage` e imprimir eventos do stream | +`--iac-code-api-key` é a chave usada pelo runtime iac-code remoto para chamar seu provedor LLM. Ela é separada de `--api-key`, que autentica a própria requisição HTTP A2A. + +As opções de thinking são metadados de requisição por mensagem. Elas também podem ser fornecidas no YAML do cliente A2A como `thinking-enabled`, `thinking-effort` e `thinking-budget`; flags de linha de comando substituem valores de configuração. Se as três forem omitidas, o cliente não envia metadados de thinking e o runtime remoto mantém os padrões configurados do provedor. Para endpoints `openai_compatible` apoiados pelo modo compatível do DashScope, uma política de thinking explícita usa os parâmetros wire nativos do DashScope, então `--no-thinking-enabled` pode enviar `extra_body.enable_thinking=false`. Emitir raw thinking de volta ao cliente A2A ainda exige que o servidor habilite `thinking-exposure: raw-thinking`. + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + Acompanhamento no mesmo contexto: ```bash diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md index 74258ce5..3fa13974 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -139,6 +139,12 @@ token: your-secret-token verify-card-secret: your-card-signing-secret require-card-signature: true cwd: /path/to/workspace +iac-code-model: qwen-plus +iac-code-api-key: provider-api-key +# Opcional: metadados de política de thinking por mensagem: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` Use `a2a-client call` para uma chamada direta de cliente Fase 1: diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 1421df3e..aa05d1b3 100644 --- a/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | Campo | Escopo | Descrição | |---|---|---| +| `thinkingEnabled` | Provedor ou modelo | Chave booleana opcional de thinking. `true` solicita que provedores/modelos compatíveis habilitem thinking; `false` solicita a desativação; se omitida, preserva o padrão do provedor/modelo. | | `thinkingBudget` | Provedor ou modelo | Orçamento de reasoning/thinking como inteiro positivo, enviado aos provedores que oferecem suporte a ele. | | `maxCompletionTokens` | Provedor ou modelo | Valor inteiro positivo que substitui `max_completion_tokens` para provedores/modelos que usam esse campo de requisição. | | `effort` | Provedor ou modelo | Substituição opcional do effort de thinking, válida apenas para modelos que oferecem controle de effort. | @@ -90,6 +93,8 @@ Valores válidos no nível do modelo em `providers..models.` su Para Alibaba Cloud DashScope e DashScope Token Plan, o IaC Code tem um `thinkingBudget=8192` integrado para `glm-5.2` e `kimi-k2.7-code`. Quando `maxCompletionTokens` não é definido, o limite da requisição é calculado como o limite normal de tokens de resposta mais o thinking budget efetivo. +Requisições A2A podem substituir essas configurações por um único turno de mensagem por meio de `message.metadata.iac_code.thinking` ou dos flags `--thinking-enabled`, `--thinking-effort` e `--thinking-budget` de `iac-code a2a-client call`. Se nenhum metadado explícito de thinking A2A for enviado, o runtime usa as configurações acima e os padrões normais do provedor. Para provedores genéricos `openai_compatible` com base URL em modo compatível do DashScope, o iac-code muda para o formato wire nativo de thinking do DashScope somente quando uma política explícita de thinking está presente. + ## Configuração de permissões de ferramentas A seção `permissions` em `settings.yml` configura quais ações de ferramentas são permitidas, negadas ou requerem confirmação: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md index dcbc746a..4d520e45 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -284,6 +284,9 @@ iac-code a2a-client --config a2a-client.yml call \ | `--context-id` | 空 | 用于后续消息的现有 A2A context ID | | `--iac-code-model` | 空 | 作为 `message.metadata.iac_code.iac_code_model` 发送的 LLM model;只在本次 message turn 覆盖 server model 配置 | | `--iac-code-api-key` | 空 | 作为 `message.metadata.iac_code.iac_code_api_key` 发送的 LLM provider API key;只在本次 message turn 覆盖 `IAC_CODE_API_KEY` 和 `.credentials.yml` | +| `--thinking-enabled`, `--no-thinking-enabled` | 空 | 作为 `message.metadata.iac_code.thinking.enabled` 发送的布尔 thinking 策略;省略时保留 server/provider 默认行为 | +| `--thinking-effort` | 空 | 作为 `message.metadata.iac_code.thinking.effort` 发送的 thinking effort;只在本次 message turn 生效 | +| `--thinking-budget` | 空 | 作为 `message.metadata.iac_code.thinking.budget` 发送的正整数 thinking budget;只在本次 message turn 生效 | | `--verify-card-secret`, `--signing-secret` | 空 | Agent Card verification 的 HMAC secret | | `--verify-card-jwks-url` | 空 | 用于 Agent Card verification 的远程 JWKS URL | | `--require-card-signature`, `--require-signature` | `false` | 拒绝未签名或无效的 Agent Cards | @@ -292,6 +295,14 @@ iac-code a2a-client --config a2a-client.yml call \ `--iac-code-api-key` 是远端 iac-code runtime 调用 LLM provider 使用的 key;它和用于认证 A2A HTTP 请求本身的 `--api-key` 不是同一个配置。 +Thinking 选项是单次 message 的 request metadata。它们也可以写在 A2A client YAML 中,配置键为 `thinking-enabled`、`thinking-effort`、`thinking-budget`;命令行 flag 会覆盖配置值。三者都省略时,client 不发送 thinking metadata,远端 runtime 会保持自身 provider 默认配置。对于 base URL 指向 DashScope compatible-mode 的 `openai_compatible` 端点,显式 thinking 策略会使用 DashScope 原生 wire 参数,因此 `--no-thinking-enabled` 可以下发 `extra_body.enable_thinking=false`。如果希望 A2A client 收到 raw thinking 事件,server 仍需启用 `thinking-exposure: raw-thinking`。 + +```yaml +thinking-enabled: false +thinking-effort: low +thinking-budget: 2048 +``` + 在同一 context 中发送后续消息: ```bash diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md index 634db0b7..c19a4eb8 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -141,6 +141,10 @@ require-card-signature: true cwd: /path/to/workspace iac-code-model: qwen-plus iac-code-api-key: provider-api-key +# 可选:单次 message 的 thinking 策略 metadata: +# thinking-enabled: false +# thinking-effort: low +# thinking-budget: 2048 ``` 使用 `a2a-client call` 进行直接的 Phase 1 client 调用: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md index 6d38af2e..802e49da 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/configuration/runtime-configuration.md @@ -72,16 +72,19 @@ activeProvider: dashscope providers: dashscope: model: glm-5.2 + thinkingEnabled: true thinkingBudget: 8192 maxCompletionTokens: 16384 models: kimi-k2.7-code: + thinkingEnabled: false thinkingBudget: 8192 maxCompletionTokens: 16384 ``` | 字段 | 作用范围 | 说明 | |---|---|---| +| `thinkingEnabled` | Provider 或模型 | 可选的布尔 thinking 开关。`true` 表示请求支持的 provider/model 开启 thinking;`false` 表示请求关闭;省略时保留 provider/model 默认行为。 | | `thinkingBudget` | Provider 或模型 | 正整数 reasoning/thinking 预算,会传给支持该参数的 provider。 | | `maxCompletionTokens` | Provider 或模型 | 正整数 `max_completion_tokens` 覆盖值,用于采用该请求字段的 provider/model。 | | `effort` | Provider 或模型 | 可选 thinking effort 覆盖值,仅对支持 effort 控制的模型生效。 | @@ -90,6 +93,8 @@ providers: 对阿里云百炼 DashScope 和 DashScope Token Plan,IaC Code 为 `glm-5.2` 和 `kimi-k2.7-code` 内置了 `thinkingBudget=8192`。未设置 `maxCompletionTokens` 时,请求上限会按普通回答 token 上限加上有效 thinking budget 计算。 +A2A 请求可以通过 `message.metadata.iac_code.thinking` 或 `iac-code a2a-client call` 的 `--thinking-enabled`、`--thinking-effort`、`--thinking-budget` flag 在单次 message turn 覆盖这些设置。如果没有发送显式 A2A thinking metadata,runtime 会使用上述配置和 provider 自身默认行为。对于 base URL 指向 DashScope compatible-mode 的通用 `openai_compatible` provider,只有存在显式 thinking 策略时,iac-code 才会切换到 DashScope 原生 thinking wire format。 + ## 工具权限配置 `settings.yml` 中的 `permissions` 部分用于配置工具操作的允许、拒绝或需要确认的规则: