Spaces:
Running
Running
Commit
Β·
486f56a
1
Parent(s):
707c1f2
feat: display HF job URLs immediately when job starts running
Browse files- Send job URL immediately after job creation (before waiting for completion)
- Store job URLs in frontend store keyed by tool_call_id
- Display job URL link next to running indicator during execution
- Remove job URL from final output section (only show status there)
- Fix race condition by passing tool_call_id directly to handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
- agent/core/agent_loop.py +10 -3
- agent/core/tools.py +4 -1
- agent/tools/jobs_tool.py +21 -1
- frontend/src/lib/ws-chat-transport.ts +7 -0
- frontend/src/store/agentStore.ts +17 -0
agent/core/agent_loop.py
CHANGED
|
@@ -612,7 +612,7 @@ class Handlers:
|
|
| 612 |
)
|
| 613 |
|
| 614 |
output, success = await session.tool_router.call_tool(
|
| 615 |
-
tool_name, tool_args, session=session
|
| 616 |
)
|
| 617 |
|
| 618 |
return (tc, tool_name, output, success)
|
|
@@ -662,8 +662,15 @@ class Handlers:
|
|
| 662 |
rejection_msg = "Job execution cancelled by user"
|
| 663 |
user_feedback = approval_decision.get("feedback")
|
| 664 |
if user_feedback:
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
tool_msg = Message(
|
| 668 |
role="tool",
|
| 669 |
content=rejection_msg,
|
|
|
|
| 612 |
)
|
| 613 |
|
| 614 |
output, success = await session.tool_router.call_tool(
|
| 615 |
+
tool_name, tool_args, session=session, tool_call_id=tc.id
|
| 616 |
)
|
| 617 |
|
| 618 |
return (tc, tool_name, output, success)
|
|
|
|
| 662 |
rejection_msg = "Job execution cancelled by user"
|
| 663 |
user_feedback = approval_decision.get("feedback")
|
| 664 |
if user_feedback:
|
| 665 |
+
# Ensure feedback is a string and sanitize any problematic characters
|
| 666 |
+
feedback_str = str(user_feedback).strip()
|
| 667 |
+
# Remove any control characters that might break JSON parsing
|
| 668 |
+
feedback_str = "".join(char for char in feedback_str if ord(char) >= 32 or char in "\n\t")
|
| 669 |
+
rejection_msg += f". User feedback: {feedback_str}"
|
| 670 |
+
|
| 671 |
+
# Ensure rejection_msg is a clean string
|
| 672 |
+
rejection_msg = str(rejection_msg).strip()
|
| 673 |
+
|
| 674 |
tool_msg = Message(
|
| 675 |
role="tool",
|
| 676 |
content=rejection_msg,
|
agent/core/tools.py
CHANGED
|
@@ -223,7 +223,7 @@ class ToolRouter:
|
|
| 223 |
|
| 224 |
@observe(name="call_tool")
|
| 225 |
async def call_tool(
|
| 226 |
-
self, tool_name: str, arguments: dict[str, Any], session: Any = None
|
| 227 |
) -> tuple[str, bool]:
|
| 228 |
"""
|
| 229 |
Call a tool and return (output_string, success_bool).
|
|
@@ -239,6 +239,9 @@ class ToolRouter:
|
|
| 239 |
# Check if handler accepts session argument
|
| 240 |
sig = inspect.signature(tool.handler)
|
| 241 |
if "session" in sig.parameters:
|
|
|
|
|
|
|
|
|
|
| 242 |
return await tool.handler(arguments, session=session)
|
| 243 |
return await tool.handler(arguments)
|
| 244 |
|
|
|
|
| 223 |
|
| 224 |
@observe(name="call_tool")
|
| 225 |
async def call_tool(
|
| 226 |
+
self, tool_name: str, arguments: dict[str, Any], session: Any = None, tool_call_id: str | None = None
|
| 227 |
) -> tuple[str, bool]:
|
| 228 |
"""
|
| 229 |
Call a tool and return (output_string, success_bool).
|
|
|
|
| 239 |
# Check if handler accepts session argument
|
| 240 |
sig = inspect.signature(tool.handler)
|
| 241 |
if "session" in sig.parameters:
|
| 242 |
+
# Check if handler also accepts tool_call_id parameter
|
| 243 |
+
if "tool_call_id" in sig.parameters:
|
| 244 |
+
return await tool.handler(arguments, session=session, tool_call_id=tool_call_id)
|
| 245 |
return await tool.handler(arguments, session=session)
|
| 246 |
return await tool.handler(arguments)
|
| 247 |
|
agent/tools/jobs_tool.py
CHANGED
|
@@ -282,11 +282,15 @@ class HfJobsTool:
|
|
| 282 |
hf_token: Optional[str] = None,
|
| 283 |
namespace: Optional[str] = None,
|
| 284 |
log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
|
|
|
|
|
| 285 |
):
|
| 286 |
self.hf_token = hf_token
|
| 287 |
self.api = HfApi(token=hf_token)
|
| 288 |
self.namespace = namespace
|
| 289 |
self.log_callback = log_callback
|
|
|
|
|
|
|
| 290 |
|
| 291 |
async def execute(self, params: Dict[str, Any]) -> ToolResult:
|
| 292 |
"""Execute the specified operation"""
|
|
@@ -512,6 +516,20 @@ class HfJobsTool:
|
|
| 512 |
namespace=self.namespace,
|
| 513 |
)
|
| 514 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
# Wait for completion and stream logs
|
| 516 |
logger.info(f"{job_type} job started: {job.url}")
|
| 517 |
logger.info("Streaming logs...")
|
|
@@ -1012,7 +1030,7 @@ HF_JOBS_TOOL_SPEC = {
|
|
| 1012 |
|
| 1013 |
|
| 1014 |
async def hf_jobs_handler(
|
| 1015 |
-
arguments: Dict[str, Any], session: Any = None
|
| 1016 |
) -> tuple[str, bool]:
|
| 1017 |
"""Handler for agent tool router"""
|
| 1018 |
try:
|
|
@@ -1035,6 +1053,8 @@ async def hf_jobs_handler(
|
|
| 1035 |
namespace=namespace,
|
| 1036 |
hf_token=hf_token,
|
| 1037 |
log_callback=log_callback if session else None,
|
|
|
|
|
|
|
| 1038 |
)
|
| 1039 |
result = await tool.execute(arguments)
|
| 1040 |
return result["formatted"], not result.get("isError", False)
|
|
|
|
| 282 |
hf_token: Optional[str] = None,
|
| 283 |
namespace: Optional[str] = None,
|
| 284 |
log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
| 285 |
+
session: Any = None,
|
| 286 |
+
tool_call_id: Optional[str] = None,
|
| 287 |
):
|
| 288 |
self.hf_token = hf_token
|
| 289 |
self.api = HfApi(token=hf_token)
|
| 290 |
self.namespace = namespace
|
| 291 |
self.log_callback = log_callback
|
| 292 |
+
self.session = session
|
| 293 |
+
self.tool_call_id = tool_call_id
|
| 294 |
|
| 295 |
async def execute(self, params: Dict[str, Any]) -> ToolResult:
|
| 296 |
"""Execute the specified operation"""
|
|
|
|
| 516 |
namespace=self.namespace,
|
| 517 |
)
|
| 518 |
|
| 519 |
+
# Send job URL immediately after job creation (before waiting for completion)
|
| 520 |
+
if self.session and self.tool_call_id:
|
| 521 |
+
await self.session.send_event(
|
| 522 |
+
Event(
|
| 523 |
+
event_type="tool_state_change",
|
| 524 |
+
data={
|
| 525 |
+
"tool_call_id": self.tool_call_id,
|
| 526 |
+
"tool": "hf_jobs",
|
| 527 |
+
"state": "running",
|
| 528 |
+
"jobUrl": job.url,
|
| 529 |
+
},
|
| 530 |
+
)
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
# Wait for completion and stream logs
|
| 534 |
logger.info(f"{job_type} job started: {job.url}")
|
| 535 |
logger.info("Streaming logs...")
|
|
|
|
| 1030 |
|
| 1031 |
|
| 1032 |
async def hf_jobs_handler(
|
| 1033 |
+
arguments: Dict[str, Any], session: Any = None, tool_call_id: str | None = None
|
| 1034 |
) -> tuple[str, bool]:
|
| 1035 |
"""Handler for agent tool router"""
|
| 1036 |
try:
|
|
|
|
| 1053 |
namespace=namespace,
|
| 1054 |
hf_token=hf_token,
|
| 1055 |
log_callback=log_callback if session else None,
|
| 1056 |
+
session=session,
|
| 1057 |
+
tool_call_id=tool_call_id,
|
| 1058 |
)
|
| 1059 |
result = await tool.execute(arguments)
|
| 1060 |
return result["formatted"], not result.get("isError", False)
|
frontend/src/lib/ws-chat-transport.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } fro
|
|
| 9 |
import { apiFetch, getWebSocketUrl } from '@/utils/api';
|
| 10 |
import { logger } from '@/utils/logger';
|
| 11 |
import type { AgentEvent } from '@/types/events';
|
|
|
|
| 12 |
|
| 13 |
// ---------------------------------------------------------------------------
|
| 14 |
// Side-channel callback interface (non-chat events forwarded to the store)
|
|
@@ -499,9 +500,15 @@ export class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
|
| 499 |
if (!this.streamController) break;
|
| 500 |
const tcId = (event.data?.tool_call_id as string) || '';
|
| 501 |
const state = (event.data?.state as string) || '';
|
|
|
|
| 502 |
|
| 503 |
if (tcId.startsWith('plan_tool')) break;
|
| 504 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
if (state === 'rejected' || state === 'abandoned') {
|
| 506 |
this.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
|
| 507 |
}
|
|
|
|
| 9 |
import { apiFetch, getWebSocketUrl } from '@/utils/api';
|
| 10 |
import { logger } from '@/utils/logger';
|
| 11 |
import type { AgentEvent } from '@/types/events';
|
| 12 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 13 |
|
| 14 |
// ---------------------------------------------------------------------------
|
| 15 |
// Side-channel callback interface (non-chat events forwarded to the store)
|
|
|
|
| 500 |
if (!this.streamController) break;
|
| 501 |
const tcId = (event.data?.tool_call_id as string) || '';
|
| 502 |
const state = (event.data?.state as string) || '';
|
| 503 |
+
const jobUrl = (event.data?.jobUrl as string) || undefined;
|
| 504 |
|
| 505 |
if (tcId.startsWith('plan_tool')) break;
|
| 506 |
|
| 507 |
+
// Store job URL if provided
|
| 508 |
+
if (jobUrl && tcId) {
|
| 509 |
+
useAgentStore.getState().setJobUrl(tcId, jobUrl);
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
if (state === 'rejected' || state === 'abandoned') {
|
| 513 |
this.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
|
| 514 |
}
|
frontend/src/store/agentStore.ts
CHANGED
|
@@ -59,6 +59,9 @@ interface AgentStore {
|
|
| 59 |
// Edited scripts (tool_call_id -> edited content)
|
| 60 |
editedScripts: Record<string, string>;
|
| 61 |
|
|
|
|
|
|
|
|
|
|
| 62 |
// Actions
|
| 63 |
setProcessing: (isProcessing: boolean) => void;
|
| 64 |
setConnected: (isConnected: boolean) => void;
|
|
@@ -80,6 +83,9 @@ interface AgentStore {
|
|
| 80 |
setEditedScript: (toolCallId: string, content: string) => void;
|
| 81 |
getEditedScript: (toolCallId: string) => string | undefined;
|
| 82 |
clearEditedScripts: () => void;
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
@@ -97,6 +103,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 97 |
plan: [],
|
| 98 |
|
| 99 |
editedScripts: {},
|
|
|
|
| 100 |
|
| 101 |
// ββ Global flags ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
|
|
@@ -180,4 +187,14 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 180 |
getEditedScript: (toolCallId) => get().editedScripts[toolCallId],
|
| 181 |
|
| 182 |
clearEditedScripts: () => set({ editedScripts: {} }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}));
|
|
|
|
| 59 |
// Edited scripts (tool_call_id -> edited content)
|
| 60 |
editedScripts: Record<string, string>;
|
| 61 |
|
| 62 |
+
// Job URLs (tool_call_id -> job URL) for HF jobs
|
| 63 |
+
jobUrls: Record<string, string>;
|
| 64 |
+
|
| 65 |
// Actions
|
| 66 |
setProcessing: (isProcessing: boolean) => void;
|
| 67 |
setConnected: (isConnected: boolean) => void;
|
|
|
|
| 83 |
setEditedScript: (toolCallId: string, content: string) => void;
|
| 84 |
getEditedScript: (toolCallId: string) => string | undefined;
|
| 85 |
clearEditedScripts: () => void;
|
| 86 |
+
|
| 87 |
+
setJobUrl: (toolCallId: string, jobUrl: string) => void;
|
| 88 |
+
getJobUrl: (toolCallId: string) => string | undefined;
|
| 89 |
}
|
| 90 |
|
| 91 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
|
|
| 103 |
plan: [],
|
| 104 |
|
| 105 |
editedScripts: {},
|
| 106 |
+
jobUrls: {},
|
| 107 |
|
| 108 |
// ββ Global flags ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 109 |
|
|
|
|
| 187 |
getEditedScript: (toolCallId) => get().editedScripts[toolCallId],
|
| 188 |
|
| 189 |
clearEditedScripts: () => set({ editedScripts: {} }),
|
| 190 |
+
|
| 191 |
+
// ββ Job URLs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
|
| 193 |
+
setJobUrl: (toolCallId, jobUrl) => {
|
| 194 |
+
set((state) => ({
|
| 195 |
+
jobUrls: { ...state.jobUrls, [toolCallId]: jobUrl },
|
| 196 |
+
}));
|
| 197 |
+
},
|
| 198 |
+
|
| 199 |
+
getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
|
| 200 |
}));
|