fix: align Lingma tool event lifecycle handling
Handle tool/invokeResult and richer tool/call/sync payloads in the client, and document/retest the verified VSCode monitoring workflow for tool events. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
39
DESIGN.md
39
DESIGN.md
@@ -708,6 +708,45 @@ uvicorn app.main:app --reload --port 8317
|
||||
| → | `chat/ask` (notify!) | 见 `_build_payload` | 不回 result;通过 server push 下推 |
|
||||
| ← | `chat/answer` | `{requestId, text, content}` | 流式 token |
|
||||
| ← | `chat/finish` | `{requestId, sessionId, ...其它元数据}` | 结束信号,含上游真实 sessionId |
|
||||
| ← | `tool/call/sync` | `{requestId?, toolCallId, toolCallStatus, parameters, results?}` | 工具状态与结果回流 |
|
||||
| ← | `tool/invoke` | `{requestId?, toolCallId, ...}` | 工具调用中间事件(兼容旧链路) |
|
||||
| ← | `tool/call/approve` | `{requestId?, toolCallId, approval, ...}` | 工具审批事件 |
|
||||
| ← | `tool/invokeResult` | `{requestId?, toolCallId, name, success, errorMessage, result}` | 工具执行结果事件 |
|
||||
|
||||
### 9.1 Tool call 监控 SOP(VSCode 真实环境)
|
||||
|
||||
目标:拿到 Lingma 扩展真实 method/字段,避免猜测协议。
|
||||
|
||||
1. 确认入口文件
|
||||
- `~/.vscode/extensions/alibaba-cloud.tongyi-lingma-*/package.json`
|
||||
- 查 `main`(当前是 `dist/extension.js`)
|
||||
|
||||
2. 在发送侧打点
|
||||
- 在 `sendRequest` / `sendNotification` 处记录 method 与参数 keys
|
||||
- 优先写文件,不依赖 console
|
||||
|
||||
3. 在入站 `tool/call/sync` handler 打点
|
||||
- 记录 `toolCallId`、`toolCallStatus`、是否包含 `results`
|
||||
|
||||
4. 用真实交互触发
|
||||
- VSCode 内发起会话并触发工具
|
||||
- 点击 Accept/Reject,观察事件闭环
|
||||
|
||||
5. 验证闭环
|
||||
- `tool/call/sync(pending|processing)`
|
||||
- `tool/call/approve`
|
||||
- `tool/invokeResult`
|
||||
- `tool/call/sync(results)`
|
||||
|
||||
6. 回滚
|
||||
- 用备份文件恢复 `dist/extension.js`
|
||||
- 避免长期携带探针到日常环境
|
||||
|
||||
**建议日志位置**:
|
||||
- `~/.lingma/vscode/sharedClientCache/logs/lingma-probe.log`
|
||||
- `~/.lingma/vscode/sharedClientCache/logs/lingma-extension.log`
|
||||
|
||||
**注意**:优先使用 VSCode,不混用 Cursor 扩展环境;`pipe` 连接模式下,扩展层探针最稳定。
|
||||
|
||||
**`chat/ask` payload 关键字段**:
|
||||
|
||||
|
||||
@@ -106,23 +106,36 @@ class LspWsRpcClient:
|
||||
@staticmethod
|
||||
def _extract_tool_event(params: dict[str, Any]) -> dict[str, Any] | None:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
if isinstance(params.get("toolCall"), dict):
|
||||
candidates.append(params["toolCall"])
|
||||
if isinstance(params.get("tool_call"), dict):
|
||||
candidates.append(params["tool_call"])
|
||||
if isinstance(params.get("tool"), dict):
|
||||
candidates.append(params["tool"])
|
||||
|
||||
def add_candidate(obj: Any) -> None:
|
||||
if isinstance(obj, dict):
|
||||
candidates.append(obj)
|
||||
|
||||
add_candidate(params.get("toolCall"))
|
||||
add_candidate(params.get("tool_call"))
|
||||
add_candidate(params.get("tool"))
|
||||
|
||||
data = params.get("data")
|
||||
if isinstance(data, dict):
|
||||
if isinstance(data.get("toolCall"), dict):
|
||||
candidates.append(data["toolCall"])
|
||||
if isinstance(data.get("tool_call"), dict):
|
||||
candidates.append(data["tool_call"])
|
||||
if isinstance(data.get("tool"), dict):
|
||||
candidates.append(data["tool"])
|
||||
add_candidate(data.get("toolCall"))
|
||||
add_candidate(data.get("tool_call"))
|
||||
add_candidate(data.get("tool"))
|
||||
|
||||
results = params.get("results")
|
||||
if isinstance(results, list):
|
||||
for item in results:
|
||||
add_candidate(item)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
fallback_id = params.get("toolCallId") or params.get("tool_call_id")
|
||||
if not fallback_id:
|
||||
return None
|
||||
return {
|
||||
"id": str(fallback_id),
|
||||
"name": str(params.get("name") or "tool"),
|
||||
"input": params.get("parameters") or {},
|
||||
"result": params.get("result"),
|
||||
}
|
||||
|
||||
raw = candidates[0]
|
||||
tool_id = (
|
||||
@@ -132,28 +145,42 @@ class LspWsRpcClient:
|
||||
or params.get("toolCallId")
|
||||
or params.get("tool_call_id")
|
||||
)
|
||||
name = raw.get("name") or raw.get("toolName") or raw.get("tool_name")
|
||||
name = (
|
||||
raw.get("name")
|
||||
or raw.get("toolName")
|
||||
or raw.get("tool_name")
|
||||
or params.get("name")
|
||||
)
|
||||
|
||||
call_input = raw.get("input")
|
||||
if call_input is None:
|
||||
call_input = raw.get("arguments")
|
||||
if call_input is None:
|
||||
call_input = raw.get("args")
|
||||
if call_input is None:
|
||||
call_input = raw.get("parameters")
|
||||
if call_input is None:
|
||||
call_input = params.get("parameters")
|
||||
|
||||
result_payload = raw.get("result")
|
||||
if result_payload is None:
|
||||
result_payload = params.get("result")
|
||||
if result_payload is None and isinstance(data, dict):
|
||||
result_payload = data.get("result")
|
||||
if result_payload is None and isinstance(raw.get("results"), list):
|
||||
result_payload = raw.get("results")
|
||||
|
||||
if not tool_id:
|
||||
return None
|
||||
|
||||
return {
|
||||
event: dict[str, Any] = {
|
||||
"id": str(tool_id),
|
||||
"name": str(name or "tool"),
|
||||
"input": call_input if call_input is not None else {},
|
||||
"result": result_payload,
|
||||
}
|
||||
if result_payload is not None:
|
||||
event["result"] = result_payload
|
||||
return event
|
||||
|
||||
async def start(self):
|
||||
self._reader_task = asyncio.create_task(self._reader_loop())
|
||||
@@ -239,14 +266,23 @@ class LspWsRpcClient:
|
||||
stream["first_chunk_at"] = time.monotonic()
|
||||
stream["chunks"].put_nowait({"type": "text", "text": text})
|
||||
|
||||
if method in {"tool/call/sync", "tool/invoke", "tool/call/approve"}:
|
||||
if method in {"tool/call/sync", "tool/invoke", "tool/call/approve", "tool/invokeResult"}:
|
||||
tool_event = self._extract_tool_event(params)
|
||||
req_id = params.get("requestId")
|
||||
stream = self._chat_streams.get(req_id)
|
||||
if stream is not None:
|
||||
tool_event = self._extract_tool_event(params)
|
||||
if tool_event is not None:
|
||||
stream["tool_events"].append(tool_event)
|
||||
stream["chunks"].put_nowait({"type": "tool", "tool": tool_event})
|
||||
stream = self._chat_streams.get(req_id) if req_id else None
|
||||
|
||||
if stream is None and tool_event is not None:
|
||||
for item in self._chat_streams.values():
|
||||
if any(evt.get("id") == tool_event["id"] for evt in item["tool_events"]):
|
||||
stream = item
|
||||
break
|
||||
|
||||
if stream is None and len(self._chat_streams) == 1:
|
||||
stream = next(iter(self._chat_streams.values()))
|
||||
|
||||
if stream is not None and tool_event is not None:
|
||||
stream["tool_events"].append(tool_event)
|
||||
stream["chunks"].put_nowait({"type": "tool", "tool": tool_event})
|
||||
|
||||
if method == "chat/finish":
|
||||
req_id = params.get("requestId")
|
||||
|
||||
@@ -288,5 +288,52 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("event: message_stop", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
class LingmaClientToolEventExtractionTests(unittest.TestCase):
|
||||
def test_extracts_tool_event_from_results_and_parameters(self) -> None:
|
||||
from app.lingma_client import LspWsRpcClient
|
||||
|
||||
event = LspWsRpcClient._extract_tool_event(
|
||||
{
|
||||
"toolCallId": "call_sync_1",
|
||||
"parameters": {"path": "README.md"},
|
||||
"results": [
|
||||
{
|
||||
"toolCallId": "call_sync_1",
|
||||
"name": "read_file",
|
||||
"result": {"ok": True},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
event,
|
||||
{
|
||||
"id": "call_sync_1",
|
||||
"name": "read_file",
|
||||
"input": {"path": "README.md"},
|
||||
"result": {"ok": True},
|
||||
},
|
||||
)
|
||||
|
||||
def test_extracts_tool_event_from_invoke_result_payload(self) -> None:
|
||||
from app.lingma_client import LspWsRpcClient
|
||||
|
||||
event = LspWsRpcClient._extract_tool_event(
|
||||
{
|
||||
"toolCallId": "call_inv_1",
|
||||
"name": "search_docs",
|
||||
"parameters": {"query": "gateway"},
|
||||
"result": {"hits": 3},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
event,
|
||||
{
|
||||
"id": "call_inv_1",
|
||||
"name": "search_docs",
|
||||
"input": {"query": "gateway"},
|
||||
"result": {"hits": 3},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user