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/ask` (notify!) | 见 `_build_payload` | 不回 result;通过 server push 下推 |
|
||||||
| ← | `chat/answer` | `{requestId, text, content}` | 流式 token |
|
| ← | `chat/answer` | `{requestId, text, content}` | 流式 token |
|
||||||
| ← | `chat/finish` | `{requestId, sessionId, ...其它元数据}` | 结束信号,含上游真实 sessionId |
|
| ← | `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 关键字段**:
|
**`chat/ask` payload 关键字段**:
|
||||||
|
|
||||||
|
|||||||
@@ -106,23 +106,36 @@ class LspWsRpcClient:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_tool_event(params: dict[str, Any]) -> dict[str, Any] | None:
|
def _extract_tool_event(params: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
if isinstance(params.get("toolCall"), dict):
|
|
||||||
candidates.append(params["toolCall"])
|
def add_candidate(obj: Any) -> None:
|
||||||
if isinstance(params.get("tool_call"), dict):
|
if isinstance(obj, dict):
|
||||||
candidates.append(params["tool_call"])
|
candidates.append(obj)
|
||||||
if isinstance(params.get("tool"), dict):
|
|
||||||
candidates.append(params["tool"])
|
add_candidate(params.get("toolCall"))
|
||||||
|
add_candidate(params.get("tool_call"))
|
||||||
|
add_candidate(params.get("tool"))
|
||||||
|
|
||||||
data = params.get("data")
|
data = params.get("data")
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
if isinstance(data.get("toolCall"), dict):
|
add_candidate(data.get("toolCall"))
|
||||||
candidates.append(data["toolCall"])
|
add_candidate(data.get("tool_call"))
|
||||||
if isinstance(data.get("tool_call"), dict):
|
add_candidate(data.get("tool"))
|
||||||
candidates.append(data["tool_call"])
|
|
||||||
if isinstance(data.get("tool"), dict):
|
results = params.get("results")
|
||||||
candidates.append(data["tool"])
|
if isinstance(results, list):
|
||||||
|
for item in results:
|
||||||
|
add_candidate(item)
|
||||||
|
|
||||||
if not candidates:
|
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]
|
raw = candidates[0]
|
||||||
tool_id = (
|
tool_id = (
|
||||||
@@ -132,28 +145,42 @@ class LspWsRpcClient:
|
|||||||
or params.get("toolCallId")
|
or params.get("toolCallId")
|
||||||
or params.get("tool_call_id")
|
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")
|
call_input = raw.get("input")
|
||||||
if call_input is None:
|
if call_input is None:
|
||||||
call_input = raw.get("arguments")
|
call_input = raw.get("arguments")
|
||||||
if call_input is None:
|
if call_input is None:
|
||||||
call_input = raw.get("args")
|
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")
|
result_payload = raw.get("result")
|
||||||
if result_payload is None:
|
if result_payload is None:
|
||||||
result_payload = params.get("result")
|
result_payload = params.get("result")
|
||||||
if result_payload is None and isinstance(data, dict):
|
if result_payload is None and isinstance(data, dict):
|
||||||
result_payload = data.get("result")
|
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:
|
if not tool_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {
|
event: dict[str, Any] = {
|
||||||
"id": str(tool_id),
|
"id": str(tool_id),
|
||||||
"name": str(name or "tool"),
|
"name": str(name or "tool"),
|
||||||
"input": call_input if call_input is not None else {},
|
"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):
|
async def start(self):
|
||||||
self._reader_task = asyncio.create_task(self._reader_loop())
|
self._reader_task = asyncio.create_task(self._reader_loop())
|
||||||
@@ -239,14 +266,23 @@ class LspWsRpcClient:
|
|||||||
stream["first_chunk_at"] = time.monotonic()
|
stream["first_chunk_at"] = time.monotonic()
|
||||||
stream["chunks"].put_nowait({"type": "text", "text": text})
|
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")
|
req_id = params.get("requestId")
|
||||||
stream = self._chat_streams.get(req_id)
|
stream = self._chat_streams.get(req_id) if req_id else None
|
||||||
if stream is not None:
|
|
||||||
tool_event = self._extract_tool_event(params)
|
if stream is None and tool_event is not None:
|
||||||
if tool_event is not None:
|
for item in self._chat_streams.values():
|
||||||
stream["tool_events"].append(tool_event)
|
if any(evt.get("id") == tool_event["id"] for evt in item["tool_events"]):
|
||||||
stream["chunks"].put_nowait({"type": "tool", "tool": tool_event})
|
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":
|
if method == "chat/finish":
|
||||||
req_id = params.get("requestId")
|
req_id = params.get("requestId")
|
||||||
|
|||||||
@@ -288,5 +288,52 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("event: message_stop", body)
|
self.assertIn("event: message_stop", body)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
class LingmaClientToolEventExtractionTests(unittest.TestCase):
|
||||||
unittest.main()
|
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