akseljoonas HF Staff Cursor commited on
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 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
- rejection_msg += f". User feedback: {user_feedback}"
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
  }));