phase2 amend: __ separator (Bedrock-safe) + post_sse error diagnostics

Phase 7 verify finding from TC #26 against :model cloud:
  HTTP 400 from openrouter→Amazon Bedrock:
  "tools.0.custom.name: String should match pattern
   '^[a-zA-Z0-9_-]{1,128}$'"

Anthropic via Bedrock validates tool names against that regex and
rejects dots. PHASE2 originally chose "." as the namespace separator
("boltzmann.list_dir"); OpenAI tolerated it, Bedrock does not.

Separator switched to "__" (two underscores) everywhere — internal
API matches on-wire shape, no transformation layer:

  - repl.lua:
    - tools_schema builds "alias__name"
    - dispatch_tool_call splits via "^(.-)__(.+)$" (non-greedy → leftmost __)
    - :mcp tool parser uses same split
    - :mcp tools formatter prints "alias__name"
    - HELP block shows <alias__name>
  - safety.lua confirm_tool_call: alias.* glob → alias__* glob
  - config.lua example block: keys rewritten
  - docs/PHASE2.md: amendment header added; §1, §2 row, §3 config.lua
    row, §5 wire-shape JSON examples, §6 auto_approve schema, §7
    meta-cmd table, §12 plan all updated. Original "." references
    preserved in commit history.

Constraint: aliases must not themselves contain "__" so the parse
stays unambiguous. Tool names from MCP servers may have underscores
freely.

Second fix bundled — uninformative broker error:
  Previously "broker error: transport: HTTP response code said error"
  Now      "broker error: transport: HTTP 400: {full body snippet}"

ffi/curl.lua M.post_sse changes:
  - FAILONERROR no longer set (was hiding the response body).
  - raw_body accumulator added alongside the SSE buffer; captures
    every byte regardless of SSE shape.
  - After perform, check status_code via curl_easy_getinfo. On >=400,
    return (nil, "HTTP <code>: <body[:400]>"). 2xx unchanged.
  - End-of-stream SSE flush only runs on 2xx (no false event on
    error bodies that aren't SSE-shaped).
  - Phase 1 callers reading just first return slot stay correct.

End-to-end verified:
  - :model cloud + tools=[boltzmann__read_file ...] +
    "Use boltzmann__read_file with path=/etc/hostname" →
    Claude emits tool_call with name="boltzmann__read_file",
    args='{"path": "/etc/hostname"}'. ok=true, transport clean.
  - Force-bad tool name "bad.name.with.dots" → err string carries
    the full bedrock 400 with the regex-pattern message visible.

TC #26 (sub-loop end-to-end) is now testable against cloud — the
error that blocked it is resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:04:57 +00:00
parent 3fa6279f5b
commit f26cbd9a3a
5 changed files with 89 additions and 49 deletions
+15 -12
View File
@@ -30,7 +30,7 @@ Meta commands:
:resume <name> load <name>.jsonl turns into the in-memory context
:mcp list show connected MCP servers
:mcp tools list tools across all sessions
:mcp tool <alias.name> show one tool's inputSchema
:mcp tool <alias__name> show one tool's inputSchema
:mcp connect <url> [a] open an MCP session at runtime
:mcp disconnect <alias> drop an MCP session
:help this message
@@ -82,8 +82,11 @@ function M.run(config)
end
-- Assemble OpenAI-shape `tools` array across all live sessions, with
-- alias.name namespacing per PHASE2.md §5. Empty array → broker omits
-- the field entirely (§12 risk row 1).
-- "alias__name" namespacing. Originally PHASE2 used "." as the separator,
-- but Anthropic via Bedrock validates tool names against
-- ^[a-zA-Z0-9_-]{1,128}$ and rejects dots — amended to "__" 2026-05-12.
-- Empty array → broker omits the field entirely (§12 risk row 1).
-- Aliases must not themselves contain "__" so the parse stays unambiguous.
local function tools_schema()
local out = {}
for alias, sess in pairs(mcp_sessions) do
@@ -91,7 +94,7 @@ function M.run(config)
out[#out + 1] = {
type = "function",
["function"] = {
name = alias .. "." .. t.name,
name = alias .. "__" .. t.name,
description = t.description or "",
parameters = t.inputSchema
or { type = "object", properties = {} },
@@ -123,14 +126,14 @@ function M.run(config)
return table.concat(parts, "\n")
end
-- Split <alias>.<tool>, look up session, call. Returns (content_string,
-- Split <alias>__<tool>, look up session, call. Returns (content_string,
-- is_error). Errors of all flavors (rpc, transport, missing alias)
-- yield a synthesized "[aish] tool ... failed: ..." string so the
-- caller always has a body for the role:"tool" turn — the strict-
-- template alternation rationale per PHASE0.md §6 and the C5/C7 fold
-- in PHASE2.md §4.
-- in PHASE2.md §4. Non-greedy "(.-)__(.+)" splits at the leftmost "__".
local function dispatch_tool_call(name, args)
local alias, tool_name = name:match("^([^.]+)%.(.+)$")
local alias, tool_name = name:match("^(.-)__(.+)$")
if not alias then
return ("[aish] tool name has no alias prefix: %s"):format(name), true
end
@@ -472,7 +475,7 @@ function M.run(config)
for _, t in ipairs(sess:list_tools()) do
any = true
local desc = (t.description or ""):gsub("\n", " ")
io.write((" %s.%-18s %s\n"):format(
io.write((" %s__%-16s %s\n"):format(
alias, t.name, desc:sub(1, 60)))
end
end
@@ -480,10 +483,10 @@ function M.run(config)
elseif sub == "tool" then
local name = sub_args:match("^%s*(%S+)")
if not name then
renderer.status("usage: :mcp tool <alias.name>"); return
renderer.status("usage: :mcp tool <alias__name>"); return
end
local alias, tname = name:match("^([^.]+)%.(.+)$")
if not alias then
local alias, tname = name:match("^(.-)__(.+)$")
if not alias or alias == "" then
renderer.status("tool name missing alias prefix: " .. name)
return
end
@@ -499,7 +502,7 @@ function M.run(config)
if not found then
renderer.status("unknown tool: " .. name); return
end
io.write((" %s.%s\n"):format(alias, found.name))
io.write((" %s__%s\n"):format(alias, found.name))
io.write((" description: %s\n"):format(found.description or "(none)"))
io.write(" inputSchema:\n ")
io.write((json.encode(found.inputSchema or {}, {indent = true})