Skip to content

fix(dashboard): read the agent tool policy off the event loop - #4946

Merged
bolichen97 merged 1 commit into
kirodotdev:mainfrom
leonlaiyc:fix/session-tool-policy-off-loop
Aug 21, 2026
Merged

fix(dashboard): read the agent tool policy off the event loop#4946
bolichen97 merged 1 commit into
kirodotdev:mainfrom
leonlaiyc:fix/session-tool-policy-off-loop

Conversation

@leonlaiyc

Copy link
Copy Markdown
Contributor

Problem / Motivation

GET /api/session-tool-policy resolves the calling session's agent config on the gateway event loop. api_session_tool_policy does the whole filesystem transaction inline:

agent_path = kiro_agents_dir() / f"{agent_name}.json"
if not agent_path.is_file():                                    # stat
    return web.json_response({})

try:
    config = json.loads(agent_path.read_text(encoding="utf-8"))  # read + parse
except (OSError, json.JSONDecodeError):
    return web.json_response({})

A stat, a read and a JSON parse, none of them offloaded, on the single loop every other gateway request shares.

Why it matters

This is not a startup path. Managed MCP servers (kirocrew-core, kirocrew-cron) call this endpoint to filter their tool lists per agent — docs/architecture/mcp.md lists it as a gateway round-trip on the MCP path — so it runs on ordinary request traffic.

The directory it reads is a real user directory (<kiro home>/agents), not a cache, so its reads are exactly as slow as that filesystem is: a network home, a cold page cache, or a Windows indexer or AV holding the file each turn a "cheap" read into loop time that every other request waits behind. Nothing about the call site bounds that cost.

The repo already treats this as a defect class of its own (no-blocking-call-on-event-loop), with the same correction landed at other call sites — #4118, #3803, #4550.

What changed (motivation → approach → change)

Symptom — an MCP tool-list filter request performs blocking filesystem work on the gateway loop.

Root cause — the existence probe, the read and the parse are written inline in an async def, so they execute on whatever thread the coroutine is running on, which is the loop's.

Change — extract the three into one sync helper and invoke it through asyncio.to_thread:

policy = await asyncio.to_thread(_read_managed_tool_policy_sync, agent_path)
if policy is None:
    return web.json_response({})

The whole transaction moves, not just the read. Offloading read_text alone would leave the stat and the json.loads on the loop — the same defect in a smaller form, and a thread-identity test would then prove the fix incomplete rather than prove it correct.

None is deliberately distinct from {}. The helper answers None for every case that already returned an empty policy without an audit record — missing file, OSError, malformed JSON, non-dict policy — and a dict for a config that was read and understood. That split is load-bearing: a policy of {} because the key is absent is an agent whose config was parsed, which is what the SEL ok record attests, while an unreadable file is not. Folding the two together would start logging success for files that were never read. Two tests pin both halves.

Not moved: kiro_agents_dir() stays on the loop — it is kiro_home() / "agents", pure path arithmetic with no I/O, and moving it would be style rather than a fix. The path-traversal guard still runs before any filesystem work, exactly as before.

One production file; no route, contract, or response-shape change.

Tests

Test Behavior locked in
test_the_agent_config_read_runs_off_the_event_loop the thread performing the read is not the loop's
test_a_missing_agent_config_is_an_empty_policy missing file → {}
test_an_unparseable_agent_config_is_an_empty_policy malformed JSON → {}, not a raise
test_a_non_dict_policy_is_an_empty_policy wrong-shaped policy → {}
test_an_agent_without_a_policy_key_is_reported_as_read a parsed config with no policy key still logs SEL ok
test_an_unread_config_is_not_logged_as_ok an unread config logs no success
test_a_traversing_agent_name_is_still_refused the path-traversal guard precedes any filesystem work
test_a_missing_session_key_is_refused deny-by-default on identity is unchanged

The proof is thread identity at the real filesystem seamPath.read_text itself — not an assertion that asyncio.to_thread was called. A spy on the offload would keep passing if the call were later moved back inline behind another wrapper; the thread the read actually runs on cannot be faked. Same shape as the accepted proof in #4118.

Fail-before / pass-after on c940485dc, with only sessions.py reverted:

=== sessions.py = origin/main ===
FAILED test_the_agent_config_read_runs_off_the_event_loop
  AssertionError: the agent config was read on the event-loop thread: the stat,
  the read and the JSON parse all block every other request on that loop
1 failed, 7 passed

=== fix applied ===
8 passed

The seven behavioural tests pass on both sides, which is the point: they demonstrate that observable behaviour is preserved rather than merely re-stated.

test_session_tool_policy_off_loop.py + test_mcp_shared_cache.py + test_mcp_call_site_auth_coverage.py: 36 passed.

Gates: flake8 · isort · scripts/check_black_formatting.py · scripts/check_brand_name.py.

Manual verification

N/A — unit coverage sufficient: the change is entirely about which thread performs the filesystem work, and the test measures that directly on the production handler with only the seam instrumented. Observing it in a running gateway would mean timing loop stalls under a slow filesystem, which is precisely the non-deterministic evidence the thread-identity assertion replaces.

Related Issues

Fixes #4945.

Same defect class as #4118, #3803 and #4550.

Checklist

  • Single commit with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) — N/A: docs/architecture/mcp.md documents this endpoint's contract (it returns the session's managedToolPolicy.exclude), which is unchanged; only the thread the read runs on differs
  • No secrets, credentials, or internal references in the diff

Contribution License Agreement

🤖 Generated with Claude Code

api_session_tool_policy resolved the calling session's agent config
inline: a stat, a read and a JSON parse on the single event loop every
other gateway request shares. This is not a startup path -- managed MCP
servers call it to filter their tool lists per agent, so it runs on
ordinary request traffic, and the agents dir is a real user directory
whose reads can be slow on a network home or while an indexer holds the
file.

Move the whole transaction to a worker through asyncio.to_thread rather
than only the read: offloading read_text alone would leave the existence
probe and the parse on the loop, which is the same defect in a smaller
form.

Observable behaviour is unchanged. The helper answers None for the cases
that already returned an empty policy without an audit record -- missing
file, OSError, malformed JSON, a non-dict policy -- and a dict for a
config that was read and understood. That split matters: a policy of {}
because the key is absent is an agent whose config WAS parsed, which is
what the SEL ok record attests, while an unreadable file is not. Folding
them together would start logging success for files that were never read.

The path-traversal guard still runs before any filesystem work, and
kiro_agents_dir() stays on the loop because it is pure path arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@leonlaiyc
leonlaiyc requested a review from a team as a code owner August 21, 2026 15:27
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 520290395d0c6bdc5ff672bcf4db779264f85d71 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 5202903

@github-actions

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Design-level review of 520290395d0c6bdc5ff672bcf4db779264f85d71 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Root-cause fix matching the repo's established to_thread pattern for this exact defect class, with behavior preserved and pinned at the real seam.

[DESIGN-REVIEWED] 5202903

@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 520290395d0c6bdc5ff672bcf4db779264f85d71 via the fork AI-review pipeline; updated in place on each push.

Review details

The candidate file's discovery pass found no candidates. I've verified the diff independently: this is a behavior-preserving refactor that moves the stat/read/parse off the event loop into asyncio.to_thread. The None-vs-{} distinction correctly preserves the SEL ok logging semantics, asyncio (line 5) and Any imports are present, and all branch outcomes map identically to the base. No grounded defect exists in the changed lines.

No findings.

[OPUS-REVIEWED] 5202903

@github-actions

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 520290395d0c6bdc5ff672bcf4db779264f85d71 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Reading complete — I reviewed the patch, the intent file, and the base tree (the handler at sessions.py:1675-1687, the repo's existing agent-config readers, and the sibling blocking reads in dashboard/handlers/). Here is the review in the contract's required shape:

First-Principles-Verdict: CONCERNS

The fix is real and derived, but its new bare reader is a fourth spelling of a job agent_discovery._read_agent_spec already does hardened.

What this change ships

Intent: stop GET /api/session-tool-policy from doing blocking filesystem work on the gateway event loop — a FIX.

  1. The agent-config stat/read/parse now runs on a worker thread, not the loop — justified (documented no-blocking-call-on-event-loop defect class, e.g. server.py:236)
  2. New sync helper _read_managed_tool_policy_sync — one consumer, but it is the offload unit, not generalized surface
  3. Eight new tests pinning thread identity and preserved behavior — declared
  4. No route, response-shape, or SEL-logging change — verified against the base handler

Watch

  • Second spelling of an existing reader. The repo already has three private "parse agent JSON → dict|None" readers: agent_discovery._read_agent_spec:147 (self-described "the one reader for both scopes", with size cap, symlink-target and AppleDouble guards, justified because "the agents directories are user-writable"), apps/bridges.py:671 _read_agent_config, and mcp_gateway/session_servers.py:95. This helper is the fourth, and the bare-read_text one — no size cap on a user-writable dir. The extraction moment was the moment to converge on the hardened one.
  • Point patch, siblings counted. Grepping read_text\( under src/kiro_crew/dashboard/handlers/ finds 40+ inline reads, many in async handlers on the same loop (memory.py:373, security.py:139, themes.py:98, knowledge.py:87, messaging.py:2402). The repo fixes this class site-by-site (fix(dashboard): offload eager spawn's agent-binding config load #4118, fix(autopilot): offload orchestrator config load #3803, fix(apps): keep the app-config writes off the event loop #4550), so this is accepted-and-deferred — but the description claims no sibling scope, and a human should know the class remains open.

Subtractions

  • Replace the stat/read/parse core of _read_managed_tool_policy_sync with a call to agent_discovery._read_agent_spec (keep only the managedToolPolicy extraction), deleting the fourth reader and inheriting the size-cap and symlink guards the agents dir was given them for.

[FIRST-PRINCIPLES-REVIEWED] 5202903

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 21, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after a description-vs-diff consistency review: every claim in the PR description is backed by the diff, and the diff carries no material change the description leaves unmentioned.

@bolichen97
bolichen97 enabled auto-merge (squash) August 21, 2026 21:31
@bolichen97
bolichen97 merged commit c4331d8 into kirodotdev:main Aug 21, 2026
64 checks passed
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 21, 2026
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…tdev#4946)

api_session_tool_policy resolved the calling session's agent config
inline: a stat, a read and a JSON parse on the single event loop every
other gateway request shares. This is not a startup path -- managed MCP
servers call it to filter their tool lists per agent, so it runs on
ordinary request traffic, and the agents dir is a real user directory
whose reads can be slow on a network home or while an indexer holds the
file.

Move the whole transaction to a worker through asyncio.to_thread rather
than only the read: offloading read_text alone would leave the existence
probe and the parse on the loop, which is the same defect in a smaller
form.

Observable behaviour is unchanged. The helper answers None for the cases
that already returned an empty policy without an audit record -- missing
file, OSError, malformed JSON, a non-dict policy -- and a dict for a
config that was read and understood. That split matters: a policy of {}
because the key is absent is an agent whose config WAS parsed, which is
what the SEL ok record attests, while an unreadable file is not. Folding
them together would start logging success for files that were never read.

The path-traversal guard still runs before any filesystem work, and
kiro_agents_dir() stays on the loop because it is pure path arithmetic.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
leonlaiyc added a commit to leonlaiyc/KiroCrew that referenced this pull request Aug 23, 2026
Every config.json update in dashboard/handlers/memory.py reads the file,
changes one key and writes it all back on the event loop. The read, the
JSON parse, and write_config_atomically -- a tmp-file write plus a rename,
which can fsync -- all block, stalling every other session while they run.
Three sites do it: api_memory_settings, _write_embed_model_config, and
_set_migrated, which runs on EVERY boot while migrated is false and so
lands the stall exactly when the gateway is bringing sessions up. This is
the class the repo has been closing site by site (kirodotdev#4118, kirodotdev#3803, kirodotdev#4550), and
kirodotdev#4946's review named this module.

The whole transaction crosses over, never just the read. Offloading the
read alone would leave the write on the loop and insert a suspension point
between the read and the write-back while the file is unguarded on disk: an
external editor, a CLI command or another process landing in that gap would
be silently overwritten by a write derived from state nobody re-checked.
That gap is zero today because the sequence is synchronous, and it stays
zero because the worker performs the whole thing without yielding. The
existing per-config lock is held across the hop, so two coroutines still
cannot interleave.

Two of the three sites also hand-rolled a reader this module already
imports. read_config_for_update is the documented companion to
write_config_atomically with 27 call sites, and api_memory_settings uses it
200 lines above; _set_migrated and _write_embed_model_config instead caught
Exception around json.loads. The helper additionally refuses a non-object
top level, where the hand-rolled version accepted a list and then raised
AttributeError from setdefault -- a crash where a fail-closed refusal was
intended.

ConfigReadError is not swallowed by the helper: what to tell the user
differs per site, and each keeps exactly the behaviour it had -- skip and
retry next boot, raise ValueError, or answer 500 config_unreadable.

api_memory_settings now validates its body before the transaction. None of
that reads the config, and a 400 previously took the lock and abandoned it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
leonlaiyc added a commit to leonlaiyc/KiroCrew that referenced this pull request Aug 23, 2026
_write_env_updates stats and reads the whole .env, re-parses it line by
line, then creates a 0600 temp file, chmods it, writes and renames. All
synchronous file I/O, and six async channel config-save handlers call it.

Three already reached it through asyncio.to_thread -- telegram, teams,
wecom -- and three called it inline on the gateway loop: slack, discord and
webex, stalling every other session for the duration of a token save.

So this is not a missing convention but an existing one applied to half the
call sites. messaging.py already uses asyncio.to_thread 29 times, and with
three siblings doing it correctly nothing in the file said which half was
right, or stopped the next channel from copying the wrong one. It is the
class the repo has been closing site by site (kirodotdev#4118, kirodotdev#3803, kirodotdev#4550), and
kirodotdev#4946's review named this module.

The WHOLE call is offloaded, never a part of it: the read-modify-write is
one transaction, and a suspension point between the read and the rename
would let a concurrent writer's keys be dropped by a write derived from
lines nobody re-read. Keeping _write_env_updates one synchronous function
on one worker preserves that without depending on the caller, which is now
said on the function itself so the next channel inherits the reason and not
just the shape.

The regression pins all six channels rather than the three that moved,
since the defect was the split and not any one site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

session-tool-policy reads the agent config on the gateway event loop

2 participants