Skip to content

Commit e49065f

Browse files
committed
fix(selfHosted): filter recovered tool results by session blockers
Sync-Source-Commit: 28753fc065eef422f30ff63813019b6430b3f005 Hand-Written-Reason: No Ark-APIs provenance marker; treated as a hand-written source commit. Release-Version: 0.7.0
1 parent ea22f54 commit e49065f

5 files changed

Lines changed: 230 additions & 4 deletions

File tree

‎src/arkruntime/selfhosted/session_tool_runner.py‎

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import threading
1010
import time
1111
from dataclasses import dataclass, field, replace
12-
from typing import Any, Dict, Iterable, List, Optional
12+
from typing import Any, Dict, Iterable, List, Optional, Set
1313

1414
from .tool_result_store import FileToolResultStore
1515
from .tools import Tool, ToolContext, ToolResult, ToolSet, error_result
@@ -23,13 +23,16 @@
2323
EVENT_TYPE_AGENT_TOOL_USE,
2424
EVENT_TYPE_SESSION_DELETED,
2525
EVENT_TYPE_SESSION_STATUS_IDLE,
26+
EVENT_TYPE_SESSION_STATUS_RESCHEDULED,
27+
EVENT_TYPE_SESSION_STATUS_RUNNING,
2628
EVENT_TYPE_SESSION_STATUS_TERMINATED,
2729
EVENT_TYPE_USER_CUSTOM_TOOL_RESULT,
2830
EVENT_TYPE_USER_TOOL_CONFIRMATION,
2931
EVENT_TYPE_USER_TOOL_RESULT,
3032
PERMISSION_ALLOW,
3133
PERMISSION_DENY,
3234
SESSION_STOP_REASON_END_TURN,
35+
SESSION_STOP_REASON_REQUIRES_ACTION,
3336
ContentBlock,
3437
Event,
3538
EventStreamUnsupported,
@@ -128,6 +131,7 @@ def run(self) -> List[ToolCallResult]:
128131
if self.options.result_store is not None:
129132
pending, processed = self.options.result_store.recover()
130133
self._state.pending_results.update(pending)
134+
self._state.recovered_results.update(pending)
131135
self._state.processed.update(processed)
132136
self._state.answered.update(processed)
133137
if self.options.prefer_stream and hasattr(self.api, "stream_events"):
@@ -200,8 +204,8 @@ def _put_stream_item(self, event_queue: "queue.Queue[object]", item: object) ->
200204

201205
def _consume_list(self) -> None:
202206
while not self._is_stopped():
203-
self._state.flush_results()
204207
self._state.reconcile(reconcile=False)
208+
self._state.flush_results()
205209
self._raise_if_idle_expired()
206210
self._sleep_or_idle(self.options.event_poll_interval_seconds)
207211

@@ -231,9 +235,14 @@ def __init__(self, runner: SessionToolRunner) -> None:
231235
self.seen: Dict[str, bool] = {}
232236
self.answered: Dict[str, bool] = {}
233237
self.pending_results: Dict[str, Event] = {}
238+
self.recovered_results: Set[str] = set()
234239
self.pending_ask: Dict[str, Event] = {}
235240
self.confirmations: Dict[str, Event] = {}
236241
self.external_tools: Dict[str, Event] = {}
242+
self.session_tool_uses: Set[str] = set()
243+
self.tool_uses_since_status: Set[str] = set()
244+
self.blocking_event_ids: Set[str] = set()
245+
self.blocking_events_known = False
237246
self.idle_armed_at = 0.0
238247
self.idle_arm_pending = False
239248

@@ -281,6 +290,7 @@ def process_listed_events(self, events: Iterable[Event], reconcile: bool = False
281290
seen_now = self.mark_event_seen(event)
282291
if not reconcile and not seen_now:
283292
continue
293+
self.observe_session_state(event)
284294
if seen_now and event.type != EVENT_TYPE_USER_TOOL_CONFIRMATION:
285295
touched_idle = True
286296
last_was_end_turn = (
@@ -298,10 +308,12 @@ def process_listed_events(self, events: Iterable[Event], reconcile: bool = False
298308
pending_ids[call_id] = True
299309
elif event.type in (EVENT_TYPE_SESSION_STATUS_TERMINATED, EVENT_TYPE_SESSION_DELETED):
300310
raise SessionTerminated("session terminated")
311+
self.reconcile_recovered_results()
301312
if touched_idle:
302313
self.disarm_idle()
303314
for event in pending:
304-
if self.is_answered(tool_use_call_id(event)):
315+
call_id = tool_use_call_id(event)
316+
if self.is_answered(call_id) or not self.should_handle_tool_use(call_id):
305317
continue
306318
self.handle_tool_use(event, event.type == EVENT_TYPE_AGENT_CUSTOM_TOOL_USE)
307319
self.release_confirmed_tool_uses()
@@ -322,6 +334,8 @@ def note_idle_event(self, event: Event) -> None:
322334
def handle_stream_event(self, event: Event) -> None:
323335
if not self.mark_event_seen(event):
324336
return
337+
self.observe_session_state(event)
338+
self.reconcile_recovered_results()
325339
self.note_idle_event(event)
326340
self.handle_event(event)
327341

@@ -351,6 +365,7 @@ def mark_answered(self, call_id: str) -> None:
351365
self.answered[call_id] = True
352366
self.processed[call_id] = True
353367
self.pending_results.pop(call_id, None)
368+
self.recovered_results.discard(call_id)
354369
self.pending_ask.pop(call_id, None)
355370
self.external_tools.pop(call_id, None)
356371
self.maybe_arm_pending_idle()
@@ -372,7 +387,7 @@ def release_confirmed_tool_uses(self) -> None:
372387
def has_unblocked_outstanding_tool(self, pending: Iterable[Event]) -> bool:
373388
for event in pending:
374389
call_id = tool_use_call_id(event)
375-
if not call_id or self.is_answered(call_id):
390+
if not call_id or self.is_answered(call_id) or not self.should_handle_tool_use(call_id):
376391
continue
377392
if call_id in self.pending_ask or call_id in self.pending_results:
378393
continue
@@ -385,6 +400,8 @@ def handle_tool_use(self, event: Event, custom: bool) -> None:
385400
return
386401
pending = self.pending_results.get(call_id)
387402
if pending is not None:
403+
if call_id in self.recovered_results:
404+
return
388405
self.send_result(call_id, event, custom, "", pending)
389406
return
390407
if not self.owns_tool(event, custom):
@@ -541,6 +558,8 @@ def retry_send_event(self, event: Event, call_id: str) -> bool:
541558

542559
def flush_results(self) -> None:
543560
for call_id, event in list(self.pending_results.items()):
561+
if call_id in self.recovered_results:
562+
continue
544563
if self.retry_send_event(event, call_id):
545564
self.mark_answered(call_id)
546565
if self.runner.options.result_store is not None:
@@ -555,6 +574,54 @@ def flush_results(self) -> None:
555574
)
556575
self.maybe_arm_pending_idle()
557576

577+
def observe_session_state(self, event: Event) -> None:
578+
if event.type in (EVENT_TYPE_AGENT_TOOL_USE, EVENT_TYPE_AGENT_CUSTOM_TOOL_USE):
579+
call_id = tool_use_call_id(event)
580+
if call_id:
581+
self.session_tool_uses.add(call_id)
582+
self.tool_uses_since_status.add(call_id)
583+
return
584+
if event.type == EVENT_TYPE_SESSION_STATUS_IDLE:
585+
self.blocking_events_known = True
586+
self.blocking_event_ids = set()
587+
if event.stop_reason_type() == SESSION_STOP_REASON_REQUIRES_ACTION:
588+
self.blocking_event_ids.update(event.stop_reason_event_ids())
589+
self.tool_uses_since_status.clear()
590+
return
591+
if event.type in (EVENT_TYPE_SESSION_STATUS_RUNNING, EVENT_TYPE_SESSION_STATUS_RESCHEDULED):
592+
self.blocking_events_known = True
593+
self.blocking_event_ids.clear()
594+
self.tool_uses_since_status.clear()
595+
596+
def should_handle_tool_use(self, call_id: str) -> bool:
597+
if not self.blocking_events_known:
598+
return True
599+
return call_id in self.blocking_event_ids or call_id in self.tool_uses_since_status
600+
601+
def reconcile_recovered_results(self) -> None:
602+
if not self.blocking_events_known:
603+
return
604+
for call_id in list(self.recovered_results):
605+
if call_id in self.blocking_event_ids and call_id in self.session_tool_uses:
606+
self.recovered_results.discard(call_id)
607+
continue
608+
if call_id in self.tool_uses_since_status:
609+
continue
610+
self.recovered_results.discard(call_id)
611+
self.pending_results.pop(call_id, None)
612+
self.runner.options.logger.warning("discard stale recovered tool result tool_use_id=%s", call_id)
613+
if self.runner.options.result_store is not None:
614+
discard = getattr(self.runner.options.result_store, "discard", None)
615+
if not callable(discard):
616+
continue
617+
try:
618+
discard(call_id)
619+
except Exception as exc: # noqa: BLE001 - optional custom store cleanup must not stop the runner.
620+
self.runner.options.logger.warning(
621+
"discard persisted tool result failed tool_use_id=%s err=%s", call_id, exc
622+
)
623+
self.maybe_arm_pending_idle()
624+
558625
def arm_idle(self) -> None:
559626
if not self.max_idle_seconds():
560627
return

‎src/arkruntime/selfhosted/tool_result_store.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ def mark_sent(self, call_id: str) -> None:
9999
record["state"] = STATE_SENT
100100
self._write_record(record)
101101

102+
def discard(self, call_id: str) -> None:
103+
"""Remove a recovered result that is no longer blocked by this session."""
104+
if not call_id:
105+
raise ValueError("call id must not be empty")
106+
try:
107+
self._path(call_id).unlink()
108+
except FileNotFoundError:
109+
return
110+
_sync_directory(self.dir)
111+
102112
def _read(self, call_id: str) -> dict:
103113
return self._read_path(self._path(call_id))
104114

‎src/arkruntime/selfhosted/types.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
EVENT_TYPE_USER_TOOL_RESULT = "user.tool_result"
2727
EVENT_TYPE_USER_CUSTOM_TOOL_RESULT = "user.custom_tool_result"
2828
EVENT_TYPE_SESSION_STATUS_IDLE = "session.status_idle"
29+
EVENT_TYPE_SESSION_STATUS_RUNNING = "session.status_running"
30+
EVENT_TYPE_SESSION_STATUS_RESCHEDULED = "session.status_rescheduled"
2931
EVENT_TYPE_SESSION_STATUS_TERMINATED = "session.status_terminated"
3032
EVENT_TYPE_SESSION_DELETED = "session.deleted"
3133

@@ -311,6 +313,14 @@ def stop_reason_type(self) -> str:
311313
return self.stop_reason
312314
return ""
313315

316+
def stop_reason_event_ids(self) -> List[str]:
317+
if not isinstance(self.stop_reason, Mapping):
318+
return []
319+
event_ids = self.stop_reason.get("event_ids")
320+
if not isinstance(event_ids, list):
321+
return []
322+
return [str(event_id) for event_id in event_ids if event_id]
323+
314324
def to_dict(self) -> Dict[str, Any]:
315325
out = dict(self.extra)
316326
out.update(

‎tests/selfhosted/test_session_tool_runner.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,28 @@
99
import pytest
1010

1111
from arkruntime.selfhosted import Event, ListEventsResponse, SessionToolRunner, SessionToolRunnerOptions
12+
from arkruntime.selfhosted.tool_result_store import ToolCallStoreDecision
1213
from arkruntime.selfhosted.tools import FunctionTool, ToolContext, ToolSet, text_result
1314

1415

16+
class _RecordingStore:
17+
def __init__(self) -> None:
18+
self.discarded = []
19+
self.marked = []
20+
21+
def begin(self, _call_id, _event):
22+
return ToolCallStoreDecision()
23+
24+
def save_result(self, _call_id, _result) -> None:
25+
pass
26+
27+
def mark_sent(self, call_id) -> None:
28+
self.marked.append(call_id)
29+
30+
def discard(self, call_id) -> None:
31+
self.discarded.append(call_id)
32+
33+
1534
class _ListAPI:
1635
def __init__(self) -> None:
1736
self.calls = 0
@@ -240,3 +259,114 @@ def mark_sent(self, _call_id):
240259
assert runner._state.answered["call-1"] is True
241260
assert "call-1" not in runner._state.pending_results
242261
assert "mark tool result sent failed" in caplog.text
262+
263+
264+
def test_recovered_results_are_filtered_against_current_blockers(tmp_path) -> None:
265+
executions = []
266+
store = _RecordingStore()
267+
tool = FunctionTool("custom", lambda _input, _context: executions.append(True) or text_result("ok"))
268+
runner = SessionToolRunner(
269+
object(),
270+
"session-1",
271+
SessionToolRunnerOptions(
272+
tools=ToolSet(),
273+
tool_context=ToolContext(workdir=str(tmp_path)),
274+
custom_tools={"custom": tool},
275+
result_store=store,
276+
),
277+
)
278+
sent = []
279+
runner._state.retry_send_event = lambda event, _call_id: sent.append(event) or True
280+
runner._state.pending_results.update(
281+
{
282+
"foreign-call": Event(type="user.custom_tool_result", custom_tool_use_id="foreign-call"),
283+
"stale-call": Event(type="user.custom_tool_result", custom_tool_use_id="stale-call"),
284+
}
285+
)
286+
runner._state.recovered_results.update(("foreign-call", "stale-call"))
287+
288+
runner._state.process_listed_events(
289+
[
290+
Event(id="stale-call", type="agent.custom_tool_use", name="custom", input={}),
291+
Event(id="current-call", type="agent.custom_tool_use", name="custom", input={}),
292+
Event(
293+
id="idle",
294+
type="session.status_idle",
295+
stop_reason={"type": "requires_action", "event_ids": ["current-call"]},
296+
),
297+
],
298+
reconcile=True,
299+
)
300+
301+
assert len(executions) == 1
302+
assert len(sent) == 1
303+
assert sent[0].custom_tool_use_id == "current-call"
304+
assert set(store.discarded) == {"foreign-call", "stale-call"}
305+
assert not runner._state.pending_results
306+
assert not runner._state.recovered_results
307+
308+
309+
def test_current_blocker_reuses_recovered_result_without_reexecution(tmp_path) -> None:
310+
executions = []
311+
store = _RecordingStore()
312+
tool = FunctionTool("custom", lambda _input, _context: executions.append(True) or text_result("ok"))
313+
runner = SessionToolRunner(
314+
object(),
315+
"session-1",
316+
SessionToolRunnerOptions(
317+
tools=ToolSet(),
318+
tool_context=ToolContext(workdir=str(tmp_path)),
319+
custom_tools={"custom": tool},
320+
result_store=store,
321+
),
322+
)
323+
sent = []
324+
result = Event(type="user.custom_tool_result", custom_tool_use_id="current-call")
325+
runner._state.pending_results["current-call"] = result
326+
runner._state.recovered_results.add("current-call")
327+
runner._state.retry_send_event = lambda event, _call_id: sent.append(event) or True
328+
329+
runner._state.process_listed_events(
330+
[
331+
Event(id="current-call", type="agent.custom_tool_use", name="custom", input={}),
332+
Event(
333+
id="idle",
334+
type="session.status_idle",
335+
stop_reason={"type": "requires_action", "event_ids": ["current-call"]},
336+
),
337+
],
338+
reconcile=True,
339+
)
340+
341+
assert not executions
342+
assert sent == [result]
343+
assert store.marked == ["current-call"]
344+
assert not store.discarded
345+
346+
347+
def test_recovered_result_waits_for_authoritative_status(tmp_path) -> None:
348+
executions = []
349+
tool = FunctionTool("custom", lambda _input, _context: executions.append(True) or text_result("ok"))
350+
runner = SessionToolRunner(
351+
object(),
352+
"session-1",
353+
SessionToolRunnerOptions(
354+
tools=ToolSet(),
355+
tool_context=ToolContext(workdir=str(tmp_path)),
356+
custom_tools={"custom": tool},
357+
),
358+
)
359+
sent = []
360+
runner._state.pending_results["current-call"] = Event(
361+
type="user.custom_tool_result", custom_tool_use_id="current-call"
362+
)
363+
runner._state.recovered_results.add("current-call")
364+
runner._state.retry_send_event = lambda event, _call_id: sent.append(event) or True
365+
366+
runner._state.process_listed_events(
367+
[Event(id="current-call", type="agent.custom_tool_use", name="custom", input={})], reconcile=True
368+
)
369+
370+
assert not executions
371+
assert not sent
372+
assert runner._state.recovered_results == {"current-call"}

‎tests/selfhosted/test_tool_result_store.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,12 @@ def test_session_store_sanitizes_session_id(tmp_path) -> None:
4040

4141
assert store.dir.parent == base
4242
assert store.dir.name.startswith("session-")
43+
44+
45+
def test_discard_removes_recovered_record(tmp_path) -> None:
46+
store = FileToolResultStore(str(tmp_path), "session-a")
47+
store.begin("call-1", Event(id="call-1", type="agent.tool_use", name="bash"))
48+
49+
store.discard("call-1")
50+
51+
assert store.recover() == ({}, {})

0 commit comments

Comments
 (0)