Skip to content

Commit b4fc83a

Browse files
committed
Seal server-owned ToolRun vertical slice
1 parent 0281b6e commit b4fc83a

24 files changed

Lines changed: 1146 additions & 211 deletions

docs/API_FRONTEND_MIGRATION.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ to the React frontend. React should prefer these APIs over local fake state.
3131
| Sessions | `GET /sessions` | Existing | Read-only | Session list |
3232
| Sessions | `POST /sessions/{session_id}/flush` | Existing | Flushes current session log | Manual flush |
3333
| Tools | `GET /tools` | Existing | Read-only | Tool registry |
34-
| Tools | `POST /tools/{tool_name}/preview` | Existing | Read-only preview | Tool dry run |
35-
| Tools | `POST /tools/{tool_name}/call` | Existing | Tool-dependent, audited | Confirmed tool execution |
34+
| Tools | `POST /tool-runs` | Sealed | Persists validated tool name, frozen args, hash, and preview | Create server-owned ToolRun |
35+
| Tools | `POST /tool-runs/{run_id}/call` | Sealed | Executes only persisted args and records the result | Confirmed tool execution by ID |
36+
| Tools | `GET /tool-runs/{run_id}` | Sealed | Read-only | Restore preview, running, failure, or result state |
37+
| Tools | `GET /tool-runs` | Sealed | Read-only | Recent ToolRun recovery |
38+
| Tools | Legacy preview/call routes | Retired (`410`) | None | Legacy clients must migrate to ToolRun IDs |
3639
| Workflows | `GET /workflows/runs` | Existing | Read-only | Workflow timeline |
3740
| Workflows | `GET /workflows/runs/{run_id}` | Existing | Read-only | Workflow detail |
3841
| Assets | `GET /assets/*` | Existing | Read-only | Role avatars and UI media |
@@ -102,3 +105,12 @@ to the React frontend. React should prefer these APIs over local fake state.
102105
- Later stages accept only the Run ID. The server owns items, digest, source block, discussion, warnings, and stage transitions.
103106
- The React controller restores `GET /news/runs/{run_id}` after a known-Run stage failure and immediately stores automatic enrich results before digest.
104107
- Discuss reserves `group_thread_id` on the NewsRun before generation and the atomic Group bundle write. A retry therefore cannot drift to a different GroupThread after a process interruption.
108+
109+
## ToolRun Final Seal
110+
111+
- `POST /tool-runs` validates and previews once, then persists the canonical tool name, arguments, and deterministic argument hash before returning the ToolRun ID.
112+
- `POST /tool-runs/{run_id}/call` has no argument payload. The service reloads the frozen server arguments and verifies their hash before execution.
113+
- SQLite compare-and-set ownership allows only one call owner. Stale running operations recover to a visible failed state without inventing a result.
114+
- Unknown or disabled tools are persisted as blocked ToolRuns; execution failures retain their reason and elapsed time for refresh recovery.
115+
- Workflow JSONL remains a best-effort audit mirror. Audit write failure cannot overturn a successfully committed ToolRun result.
116+
- React persists the active ToolRun ID and restores it through `GET /tool-runs/{run_id}`. Tool orchestration and parameter invalidation live in `toolController`, not `App.tsx`.

frontend/src/App.tsx

Lines changed: 32 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@ import {
1616
} from "lucide-react";
1717
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
1818
import {
19-
callLocalKnowledge,
2019
loadApiSnapshot,
2120
loadRole,
2221
loadWorkflowRun,
2322
lookupNews,
24-
previewLocalKnowledge,
2523
queryRag,
2624
saveRuntimeSettings,
2725
uploadDocuments
@@ -40,6 +38,7 @@ import { createEmptyRag, useChatController } from "./features/chat/chatControlle
4038
import { ChatPanel } from "./features/single-chat/ChatPanel";
4139
import { SESSION_STORAGE_KEY, seedMessages } from "./features/single-chat/chatHistory";
4240
import { ToolPanel } from "./features/tools/ToolPanel";
41+
import { useToolController, type ToolController } from "./features/tools/toolController";
4342
import { roleLabel, roleOptions } from "./features/roles/roleCatalog";
4443
import { WechatPanel } from "./features/wechat-workspace/WechatPanel";
4544
import { useGroupChatController } from "./features/group-chat/groupChatController";
@@ -56,7 +55,6 @@ import type {
5655
RagQueryResponse,
5756
RagSettings,
5857
RoleResponse,
59-
ToolInvocationResponse,
6058
WorkspaceState,
6159
WorkflowRunDetail
6260
} from "./types";
@@ -533,15 +531,7 @@ function Inspector({
533531
selectedRun,
534532
loadingRunId,
535533
selectRun,
536-
toolPreview,
537-
toolCall,
538-
previewTool,
539-
callTool,
540-
isPreviewing,
541-
isCalling,
542-
toolCanCall,
543-
toolCallBlockedReason,
544-
toolInvocationLabel,
534+
toolController,
545535
onRestoreSession,
546536
onArchiveSession,
547537
newsController,
@@ -575,15 +565,7 @@ function Inspector({
575565
selectedRun: WorkflowRunDetail | null;
576566
loadingRunId: string;
577567
selectRun: (runId: string) => void;
578-
toolPreview: ToolInvocationResponse | null;
579-
toolCall: ToolInvocationResponse | null;
580-
previewTool: () => void;
581-
callTool: () => void;
582-
isPreviewing: boolean;
583-
isCalling: boolean;
584-
toolCanCall: boolean;
585-
toolCallBlockedReason: string;
586-
toolInvocationLabel: string;
568+
toolController: ToolController;
587569
onRestoreSession: (sessionId: string) => void;
588570
onArchiveSession: (sessionId: string) => void;
589571
newsController: NewsController;
@@ -643,15 +625,15 @@ function Inspector({
643625
/>
644626
<ToolPanel
645627
toolCount={snapshot.tools.length}
646-
toolPreview={toolPreview}
647-
toolCall={toolCall}
648-
previewTool={previewTool}
649-
callTool={callTool}
650-
isPreviewing={isPreviewing}
651-
isCalling={isCalling}
652-
canCall={toolCanCall}
653-
callBlockedReason={toolCallBlockedReason}
654-
invocationLabel={toolInvocationLabel}
628+
run={toolController.run}
629+
error={toolController.error}
630+
previewTool={toolController.preview}
631+
callTool={toolController.call}
632+
isPreviewing={toolController.isPreviewing}
633+
isCalling={toolController.isCalling}
634+
canCall={toolController.canCall}
635+
callBlockedReason={toolController.callBlockedReason}
636+
invocationLabel={toolController.invocationLabel}
655637
/>
656638
<SessionsPanel sessions={snapshot.sessions} activeSessionId={singleChatSessionId} isSending={isSending} onRestore={onRestoreSession} onArchive={onArchiveSession} />
657639
<RoadmapPanel />
@@ -670,17 +652,14 @@ export default function App() {
670652
const [keepCurrentRole, setKeepCurrentRole] = useState(false);
671653
const [conversationInstruction, setConversationInstruction] = useState("");
672654
const [isSavingSettings, setIsSavingSettings] = useState(false);
673-
const [isPreviewing, setIsPreviewing] = useState(false);
674-
const [isCalling, setIsCalling] = useState(false);
675655
const [isSearching, setIsSearching] = useState(false);
676656
const wechatThreadId = workspaceRuntime.activeGroupThreadId ?? snapshot.wechat?.group_thread_id;
677657
const newsRunId = workspaceRuntime.activeNewsRunId;
658+
const toolRunId = workspaceRuntime.activeToolRunId;
678659
const setWechatThreadId = (threadId?: string) => dispatchWorkspace({ type: "SET_ACTIVE_GROUP_THREAD", threadId });
679660
const setNewsRunId = (runId?: string) => dispatchWorkspace({ type: "SET_ACTIVE_NEWS_RUN", runId });
661+
const setToolRunId = (runId?: string) => dispatchWorkspace({ type: "SET_ACTIVE_TOOL_RUN", runId });
680662
const [ragSearch, setRagSearch] = useState<RagQueryResponse | null>(null);
681-
const [toolPreview, setToolPreview] = useState<ToolInvocationResponse | null>(null);
682-
const [toolCall, setToolCall] = useState<ToolInvocationResponse | null>(null);
683-
const [previewedInvocation, setPreviewedInvocation] = useState<LocalKnowledgeInvocation | null>(null);
684663
const [selectedRun, setSelectedRun] = useState<WorkflowRunDetail | null>(null);
685664
const [roleDetail, setRoleDetail] = useState<RoleResponse | null>(null);
686665
const [webLookup, setWebLookup] = useState<NewsLookupResponse | null>(null);
@@ -752,17 +731,15 @@ export default function App() {
752731
setOperationError,
753732
clearChatArtifacts: () => {
754733
setRagSearch(null);
755-
setToolPreview(null);
756-
setToolCall(null);
757-
setPreviewedInvocation(null);
734+
operationRegistry.invalidate("tool");
735+
setToolRunId(undefined);
758736
setSelectedRun(null);
759737
},
760738
onWorkspaceCancelled: () => {
761739
groupController.cancelWorkspace();
762740
newsController.cancelWorkspace();
741+
operationRegistry.invalidate("tool");
763742
setIsNewsBusy(false);
764-
setIsPreviewing(false);
765-
setIsCalling(false);
766743
},
767744
refresh,
768745
});
@@ -784,22 +761,14 @@ export default function App() {
784761
topK: ragSettings.chatTopK,
785762
minScore: ragSettings.minScore
786763
};
787-
const toolCanCall = Boolean(
788-
toolPreview &&
789-
previewedInvocation &&
790-
previewedInvocation.query === currentToolInvocation.query &&
791-
previewedInvocation.retrievalMode === currentToolInvocation.retrievalMode &&
792-
previewedInvocation.topK === currentToolInvocation.topK &&
793-
previewedInvocation.minScore === currentToolInvocation.minScore
794-
);
795-
const toolCallBlockedReason = !toolPreview
796-
? ""
797-
: toolCanCall
798-
? ""
799-
: "输入或 RAG 参数已变化,请重新预览后再调用。";
800-
const toolInvocationLabel = previewedInvocation
801-
? `${previewedInvocation.query} · ${previewedInvocation.retrievalMode} · top_k=${previewedInvocation.topK} · min_score=${previewedInvocation.minScore}`
802-
: "";
764+
const toolController = useToolController({
765+
invocation: currentToolInvocation,
766+
activeRunId: toolRunId,
767+
setActiveRunId: setToolRunId,
768+
onCalled: async () => {
769+
await refresh();
770+
},
771+
});
803772

804773
useEffect(() => {
805774
const saved = window.localStorage.getItem(SESSION_STORAGE_KEY);
@@ -809,12 +778,16 @@ export default function App() {
809778
const restoredThreadId = String(parsed.singleChatSessionId ?? parsed.sessionId ?? "");
810779
const restoredWechatThreadId = String(parsed.wechatThreadId ?? "");
811780
const restoredNewsRunId = String(parsed.newsRunId ?? "");
781+
const restoredToolRunId = String(parsed.toolRunId ?? "");
812782
if (restoredWechatThreadId) {
813783
setWechatThreadId(restoredWechatThreadId);
814784
}
815785
if (restoredNewsRunId) {
816786
setNewsRunId(restoredNewsRunId);
817787
}
788+
if (restoredToolRunId) {
789+
setToolRunId(restoredToolRunId);
790+
}
818791
if (parsed.chatSettings && typeof parsed.chatSettings === "object") {
819792
sessionSettingsRestoredRef.current = true;
820793
setChatSettings({ ...CHAT_SETTINGS_DEFAULTS, ...(parsed.chatSettings as ChatSettings) });
@@ -896,6 +869,7 @@ export default function App() {
896869
singleChatSessionId,
897870
wechatThreadId,
898871
newsRunId,
872+
toolRunId,
899873
chatSettings,
900874
ragSettings,
901875
ragEnabled,
@@ -911,7 +885,7 @@ export default function App() {
911885
window.localStorage.setItem(SESSION_STORAGE_KEY, payload);
912886
}, isSending ? 800 : 200);
913887
return () => window.clearTimeout(timeout);
914-
}, [singleChatMessages, singleChatSessionId, wechatThreadId, newsRunId, chatSettings, ragSettings, ragEnabled, keepCurrentRole, conversationInstruction, lastChat, isSending]);
888+
}, [singleChatMessages, singleChatSessionId, wechatThreadId, newsRunId, toolRunId, chatSettings, ragSettings, ragEnabled, keepCurrentRole, conversationInstruction, lastChat, isSending]);
915889

916890
useEffect(() => {
917891
const flushSessionStorage = () => {
@@ -983,35 +957,6 @@ export default function App() {
983957
}
984958
};
985959

986-
const previewTool = async () => {
987-
const { operationId, generationId } = operationRegistry.start("tool");
988-
setIsPreviewing(true);
989-
setToolCall(null);
990-
const invocation = { ...currentToolInvocation };
991-
try {
992-
const response = await previewLocalKnowledge(invocation);
993-
if (!operationRegistry.isCurrent(operationId, generationId)) return;
994-
setToolPreview(response);
995-
setPreviewedInvocation({ ...invocation, previewId: response.run_id });
996-
} catch (error) {
997-
if (!operationRegistry.isCurrent(operationId, generationId)) return;
998-
setPreviewedInvocation(null);
999-
setToolPreview({
1000-
tool_name: "retrieve_local_knowledge",
1001-
status: "failed",
1002-
output: {},
1003-
reason: error instanceof Error ? error.message : "预览失败",
1004-
elapsed_ms: 0,
1005-
run_id: ""
1006-
});
1007-
} finally {
1008-
if (operationRegistry.isCurrent(operationId, generationId)) {
1009-
setIsPreviewing(false);
1010-
}
1011-
operationRegistry.complete(operationId);
1012-
}
1013-
};
1014-
1015960
const saveSettings = async () => {
1016961
setIsSavingSettings(true);
1017962
setOperationError("");
@@ -1063,38 +1008,6 @@ export default function App() {
10631008
}
10641009
};
10651010

1066-
const callTool = async () => {
1067-
if (!previewedInvocation || !toolCanCall || isCalling) {
1068-
return;
1069-
}
1070-
const { operationId, generationId } = operationRegistry.start("tool");
1071-
setIsCalling(true);
1072-
try {
1073-
const result = await callLocalKnowledge(previewedInvocation);
1074-
if (!operationRegistry.isCurrent(operationId, generationId)) return;
1075-
setToolCall(result);
1076-
await refresh();
1077-
if (result.run_id) {
1078-
await selectRun(result.run_id);
1079-
}
1080-
} catch (error) {
1081-
if (!operationRegistry.isCurrent(operationId, generationId)) return;
1082-
setToolCall({
1083-
tool_name: "retrieve_local_knowledge",
1084-
status: "failed",
1085-
output: {},
1086-
reason: error instanceof Error ? error.message : "调用失败",
1087-
elapsed_ms: 0,
1088-
run_id: ""
1089-
});
1090-
} finally {
1091-
if (operationRegistry.isCurrent(operationId, generationId)) {
1092-
setIsCalling(false);
1093-
}
1094-
operationRegistry.complete(operationId);
1095-
}
1096-
};
1097-
10981011
const handleLookupNews = async () => {
10991012
const query = newsQuery.trim();
11001013
if (!query || isNewsBusy) {
@@ -1226,15 +1139,7 @@ export default function App() {
12261139
selectedRun={selectedRun}
12271140
loadingRunId={loadingRunId}
12281141
selectRun={selectRun}
1229-
toolPreview={toolPreview}
1230-
toolCall={toolCall}
1231-
previewTool={previewTool}
1232-
callTool={callTool}
1233-
isPreviewing={isPreviewing}
1234-
isCalling={isCalling}
1235-
toolCanCall={toolCanCall}
1236-
toolCallBlockedReason={toolCallBlockedReason}
1237-
toolInvocationLabel={toolInvocationLabel}
1142+
toolController={toolController}
12381143
onRestoreSession={restoreSession}
12391144
onArchiveSession={archiveCurrentSession}
12401145
newsController={newsController}

frontend/src/api.test.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
3-
callLocalKnowledge,
3+
callToolRun,
44
archiveSession,
55
createNewsRun,
66
digestNewsRun,
@@ -10,7 +10,7 @@ import {
1010
searchNewsRun,
1111
loadApiSnapshot,
1212
lookupNews,
13-
previewLocalKnowledge,
13+
createToolRun,
1414
queryRag,
1515
searchWechat,
1616
sendChatStream,
@@ -203,25 +203,24 @@ describe("local knowledge tool calls", () => {
203203
query: "RAG",
204204
retrievalMode: "hybrid" as const,
205205
topK: 7,
206-
minScore: 0.33,
207-
previewId: "preview-1"
206+
minScore: 0.33
208207
};
209208

210-
await previewLocalKnowledge(invocation);
211-
await callLocalKnowledge(invocation);
209+
await createToolRun(invocation);
210+
await callToolRun("preview-1");
212211

213212
const [, previewInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
214-
const [, callInit] = fetchMock.mock.calls[1] as unknown as [string, RequestInit];
213+
const [callUrl, callInit] = fetchMock.mock.calls[1] as unknown as [string, RequestInit];
215214
const previewBody = JSON.parse(String(previewInit.body));
216-
const callBody = JSON.parse(String(callInit.body));
215+
expect(previewBody.tool_name).toBe("retrieve_local_knowledge");
217216
expect(previewBody.args).toEqual({
218217
query: "RAG",
219218
retrieval_mode: "hybrid",
220219
top_k: 7,
221220
min_score: 0.33
222221
});
223-
expect(callBody.run_id).toBe("preview-1");
224-
expect(callBody.args).toEqual(previewBody.args);
222+
expect(callUrl).toBe("/tool-runs/preview-1/call");
223+
expect(callInit.body).toBeUndefined();
225224
});
226225
});
227226

0 commit comments

Comments
 (0)