Skip to content

fix(agent,tools): stop the loop reporting work it never did - #3972

Closed
kovtcharov-amd wants to merge 6 commits into
mainfrom
kalin/agent-loop-tool-fixes
Closed

kovtcharov-amd wants to merge 6 commits into
mainfrom
kalin/agent-loop-tool-fixes

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

An agent run could end with "Task completed with <tool>. No further action needed." having written nothing at all — and nothing downstream could tell that apart from a real answer. The status was success, every tool call returned success, and the file the user asked for did not exist. This fixes that and five more defects behind it, all found by running real tasks to completion and reading what the agent actually did rather than what it said.

Measured across three-run batches on a 27-task suite: shell-tool refusals fell from 55–76% of calls to 6–10%, and tasks accomplished rose from 19/27 to 26/27.

The most useful finding is one that had been hiding a fix: the guard meant to catch a missing output file called a symbol that was never imported, and both call sites caught the resulting NameError under a blanket except Exception. It had never executed — so two earlier rounds of measurement were scoring a feature that did not exist. Its unit tests passed throughout, because they tested the regex and not the loop.

🔍 What changed
Defect Issue
Missing-output guard never ran (unimported symbol, swallowed NameError) #3942
A repeated tool call ended the turn claiming success #3943
search_file could not find a file named exactly (ci.log, pyproject.toml) #3941
Content search skipped .toml, .cfg, .ts and every extensionless file #3957
Unpaired </think> left a reasoning model's deliberation in the answer #3945
Shell refused linters it already ran via python -m, and its hint was untrue #3971

Both output-guard handlers narrow from except Exception to OSError, so a programming error crashes instead of silently disabling a guard.

The extension allowlists are replaced by a binary sniff (a NUL byte in the first 4 KB — the rule grep -r uses) rather than extended. Any such list is wrong for the next language someone searches.

Also included is the task-execution harness these were found with: the real agent loop run to completion, a fresh sandbox and process per attempt with HOME/GAIA_HOME redirected, correctness decided by a verifier's exit code, and judged quality kept in a separate column and never blended with it. Every verifier is proved two-sided — it must reject an untouched workspace and accept a hand-written oracle.

Two scoped but not fixed here, because they are design decisions rather than bugs: #3967 (78 tools, 11 of them named search_*; the model picked the content search zero times in 688 calls) and #3946 (python -m pytest bypasses the pytest grant policy).

Test plan

  • python -m pytest tests/unit/agents tests/unit/factory tests/unit/test_shell_guardrails.py tests/unit/test_file_tools.py -q — 1517 passed locally
  • python util/lint.py --black --isort --flake8
  • Confirm the loop-break path never claims completion: python -m pytest tests/unit/agents/test_loop_break_truthful.py tests/unit/agents/test_agent_source_invariants.py -q
  • Confirm the guards fire in the real loop, not just in a regex: python -m pytest tests/unit/agents/test_output_guards_in_loop.py -q
  • Confirm the shell docstring's claims match the allowlist: python -m pytest tests/unit/agents/test_python_console_scripts.py -q

…it use the tools it has

An agent run could end with "Task completed" having written nothing, and the
caller had no way to tell that from a real answer. Six defects behind that,
each found by running real tasks to completion and reading what the agent
actually did:

- the guard that re-prompts for a missing output file called a symbol nobody
  imported, and a blanket except swallowed the NameError -- it had never run
- a repeated tool call ended the turn claiming success; it now asks for a
  different approach once, then says the work is unfinished
- search_file could not find a file named exactly (ci.log, pyproject.toml):
  an extension allowlist hid it, and the agent read the empty result as an
  empty workspace
- content search skipped .toml, .cfg, .ts and every extensionless file, so
  'bump the version wherever it is declared' never saw pyproject.toml
- an unpaired </think> left a reasoning model's deliberation in the answer
- the shell refused linters it already ran via 'python -m', and its hint
  claimed only read-only commands were allowed long after that stopped being
  true -- so the agent stopped trying to verify its own work

Also adds the task-execution harness these were found with: real agent loop,
isolated sandbox per attempt, correctness decided by a verifier's exit code
and never blended with judged quality.

Measured across three-run batches on the 27-task suite, shell refusals fell
from 55-76% of calls to 6-10%, and accomplished rose from 19/27 to 26/27.

Issues: #3941 #3942 #3943 #3945 #3946 #3957 #3967 #3971
@github-actions github-actions Bot added documentation Documentation changes dependencies Dependency updates devops DevOps/infrastructure changes mcp MCP integration changes llm LLM backend changes cli CLI changes eval Evaluation framework changes tests Test changes performance Performance-critical changes agents agent::email Email agent changes daemon Daemon supervisor / sidecar control plane sidecar Agent sidecar contract / harness labels Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Skill audit

Skill Verdict Claimed tier Cleared tiers Findings Rules
.claude/skills/building-eval-dataset ✅ ALLOW experimental experimental, community none —
.claude/skills/driving-the-tui ✅ ALLOW experimental experimental, community none —

✅ All audited skills cleared the tier they claim.

Per-finding detail is withheld here on purpose. Read it in the Security > Code scanning tab, or download the skill-audit-reports artifact from this run. Offending source text is withheld from CI everywhere — reproduce it locally with gaia skill audit <dir> --show-snippets.

@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This PR bundles a large new eval-dataset subsystem (gaia.factory.dataset / gaia.factory.tasks) with a set of agent-loop, shell-sandbox and context-window behaviour changes. The new work is thoughtful and unusually well-documented, but three things block merge.

The diff undoes recent work already on main — including a security fix. As given, it reverts the change that stopped raw OAuth provider response bodies reaching logs and user-facing errors (those responses answer requests carrying an authorization/device code), the email "a mailbox failed during the scan" caveat, the daemon --dev-src-dir error messages, a dead-link docs fix, and a whole bundle of dropped-connection handling across seven runtime files plus its test. It also rolls back pinned CI action SHAs, the code-signing action major version, and the Electron and Zod pins. This is the signature of a branch built on an older main — rebase onto current main and regenerate the diff, then confirm none of those reversals actually ship.

🔒 SECURITY CONCERN: the reverted OAuth error-body handling reintroduces a fixed credential-adjacent disclosure path, and separately the shell allowlist is widened in ways that need a maintainer decision. @kovtcharov-amd — please weigh in on both before this merges. Details are in the collapsed section; no exploit specifics here.

The model-loading change has a bug that fires on every ordinary run. Context pinning is now off by default, but the "is it already loaded at a big enough window?" check still compares against the (now absent) expected size. The comparison throws, gets swallowed by a catch-all, and the result is that the optimisation added to avoid redundant model loads never runs — every request re-issues a load. No test covers it.

The new dataset builder won't run for anyone who installed GAIA normally. Its two new packages aren't listed in the package manifest, so the documented python -m gaia.factory.dataset.build fails with a missing-module error outside a source checkout.

Also worth resolving before merge: the three new agent-loop guards fire only once per process rather than once per turn, so they stop protecting anything after the first trigger; and removing the step ceiling plus unpinning the context window are both model-behaviour changes that the project asks to be backed by an eval run.

Real-world evidence

The automated evidence stage failed before producing any evidence — evidence-bundle.md says so explicitly: "EVIDENCE HARNESS FAILED TO RUN — this is NOT 'nothing to test'. The changed surface was NOT exercised." I also could not execute anything myself; the shell in this review environment is broken (bwrap: Can't mkdir parents for /run/containerd/containerd.sock), so no test, lint, or CLI run happened here either.

This verdict therefore rests on static review alone. That matters more than usual for this PR, because the surfaces it changes are exactly the ones static review is weakest on: the shell tool's allowlist, the agent loop's stopping behaviour, and Lemonade model loading. Before merge I'd want to see a real gaia eval agent run compared against the committed baseline (step limit and context pinning are both LLM-affecting), and a real run_shell_command transcript showing the new git and Python permissions behaving as described.

🔍 Technical details

🔴 Critical

1. Diff reverts commits already on main (whole-PR issue)

The PR head genuinely carries the pre-fix content — I verified against the checked-out tree, not just the diff (.github/workflows/claude.yml:1068 is pinned to v1.0.216; answer_grounding.py:692 returns without the caveat).

Reverted, with the commit each belongs to:

Area File(s) Reverts
🔒 OAuth error bodies src/gaia/connectors/flow.py:2328,2378,2413 + tests/unit/connectors/test_device_flow.py, test_oauth_error_classification.py #3875
Email pre-scan caveat hub/agents/email/python/gaia_agent_email/answer_grounding.py:689, its tests, CHANGELOG.md #3780
Daemon --dev-src-dir errors src/gaia/daemon/sidecars/spec.py:205-326 + tests/unit/test_daemon_dev_anchor_spec.py #3869
Dead SD links docs/releases/v0.15.3.mdx:58 #3924
RemoteDisconnected/ConnectionError handling cli.py, eval/runner.py, installer/lemonade_installer.py, llm/lemonade_embedded.py, mcp/client/transports/http.py, util/check_component_core_api.py, util/verify_publish_pipeline.py, 4 integration tests, and deletes tests/unit/test_remote_disconnected_handling.py —
Pinned action SHAs v1.0.224 → v1.0.216 9 workflows —
signpath/github-action-submit-signing-request v3 → v2 .github/workflows/build-installers.yml:290 —
electron 44.3.0 → 44.1.1, zod 4.6.5 → 4.5.4 package.json, 3 lockfiles/manifests —

The flow.py one is the security-relevant entry: _structured_oauth_error() is deleted and response.text[:300] goes back into error_description and into the device-flow ConnectorsError message. That is the exact fallback #3875 removed.

2. expected_ctx is compared to None on the default path — src/gaia/llm/lemonade_client.py:3607

expected_ctx is now Optional[int] and is None for every non-pin_ctx local model (i.e. the default Gemma-4-E4B-it-GGUF GPU path) and for every remote host. When the model is already loaded:

if loaded_ctx >= expected_ctx:   # TypeError: '>=' not supported between 'int' and 'NoneType'

The except Exception at :3621 swallows it, so the failure is invisible — but the #2053 "skip a redundant /load" fast path is now dead in the default configuration and every _ensure_model_loaded() re-issues a load. Two follow-on defects in the same block: :3616 logs GAIA expects ctx=None, and the expected_ctx == DEFAULT_CONTEXT_SIZE branch at :3667 is now unreachable (its comment still claims "either from MODELS or the GAIA-wide default").

            if loaded_entry is not None:
                loaded_ctx = (
                    loaded_entry.get("recipe_options", {}).get("ctx_size", 0) or 0
                )
                if expected_ctx is None or loaded_ctx >= expected_ctx:
                    self.log.debug(
                        f"Model '{model}' already loaded at ctx={loaded_ctx} "
                        f"(expected >= {expected_ctx or 'unpinned'})"
                    )
                    return

Please add a tests/test_lemonade_client.py case for the already-loaded + unpinned combination — nothing in the PR exercises it, and the broad except means a regression here can never turn a test red on its own.

🟡 Important

3. New factory subpackages missing from setup.py — setup.py:81-82

packages= is an explicit list; only gaia.factory and gaia.factory.harvest are there. gaia.factory.dataset and gaia.factory.tasks are not, so they are excluded from the wheel and python -m gaia.factory.dataset.build — the command .claude/skills/building-eval-dataset/SKILL.md documents — raises ModuleNotFoundError on a pip-installed GAIA. It works from a source checkout, which is the hidden-state masking CLAUDE.md warns about.

        "gaia.factory",
        "gaia.factory.harvest",
        "gaia.factory.dataset",
        "gaia.factory.tasks",

4. The three new loop guards never reset between turns — src/gaia/agents/base/agent.py:6603, 7191, 7228

_nudged_repeat_loop, _nudged_idle_turn and _nudged_missing_output are set to True and never cleared. In any long-lived agent (Agent UI session, chat session, daemon sidecar) each guard fires once for the life of the process, not once per turn — so every query after the first gets no repeat-loop nudge, no stopped-before-starting nudge and no missing-output nudge. The convention is right above, at :5127, where _single_tool_done is reset per turn:

        self._current_query = user_input
        self._single_tool_done = False
        self._nudged_repeat_loop = False
        self._nudged_idle_turn = False
        self._nudged_missing_output = False

The new tests (test_idle_turn_guard.py, test_output_guards_in_loop.py) all construct a fresh agent, so none of them would catch this — a second process_query() on the same instance would.

5. 🔒 Shell allowlist widening needs maintainer sign-off — src/gaia/agents/tools/shell_tools.py:87-120, 204-226, 747-760

Three changes land together, and the reasoning for each is written up well; the question is whether the combination is the intended posture.

  • python / python3 / py are allowlisted. python -c "…" is arbitrary code, so the command allowlist and the operator blocklist no longer constrain what a permitted call can do. The stated justification — execute_python_file already reaches the interpreter under the same allowed_paths, and both tools sit in TOOLS_REQUIRING_CONFIRMATION (agent.py:362-366) — holds up. Worth an explicit maintainer decision anyway, since it also bypasses the shell:execute:pytest grant policy in gaia.skills.binaries (the PR acknowledges this at :129-133).
  • LOCAL_WRITE_GIT_COMMANDS adds rm, checkout, restore, stash. These are not merely "local writes" — git rm <path> deletes the file from disk, and git checkout -- . / git restore . / git stash discard uncommitted work irreversibly. There is no flag-level filtering, and the new tool docstring at :1024-1026 simultaneously tells the model "file mutation (rm, mv, cp…) — use write_file and edit_file". That contradiction is worth fixing regardless of the policy call. If the motivating case is untracking a secret, git rm --cached specifically is a much narrower grant than bare git rm.
  • Rate limits go 3 → 30 per 10s and 10 → 120 per minute, which is where the runaway-loop backstop sits now that the step ceiling is gone (see Prevent Users from Installing Hybrid mode on Unsupported Systems #6).

tests/unit/test_shell_guardrails.py:23586-23612 covers git rm --cached allowed and push/rebase refused, but nothing pins the destructive cases either way. A test asserting the intended answer for git checkout -- . and git rm -rf . would make the decision durable.

6. Unbounded steps + unpinned context, with no eval run — agent.py:787, lemonade_client.py:3582-3590

DEFAULT_MAX_STEPS = 0 (no limit) and default-off context pinning are both squarely in CLAUDE.md's "REQUIRES an eval run before merge" list, and both are exactly the kind of change unit tests cannot gate. Two specific risks:

  • The DEFAULT_MAX_STEPS comment is candid that "nothing else bounds a runaway loop today" and argues the cap is wrong for interactive work — but the default now also applies to gaia daemon, gaia schedule, and eval runs, which are unattended by definition. Consider defaulting unattended entry points to a ceiling even if interactive ones don't.
  • Unpinning context reopens the shape of [Bug]: 1.4MB file indexed no response to queries in the UI #1030 from the other direction: MODELS[…].min_ctx_size was a floor that stopped Lemonade auto-loading Gemma at its own 32K default. With pin_ctx=False everywhere except the NPU entry, nothing enforces that floor for GPU any more.

Please attach a gaia eval agent --category rag_quality run diffed against tests/fixtures/eval_baselines/…/scorecard_rag_quality.json.

7. Written-file guards resolve against os.getcwd() — agent.py:5065, 7223

unwritten_claims() and missing_requested_outputs() join relative names onto the process CWD, which is not necessarily the agent's workspace or anything in allowed_paths. A correct answer that wrote its file elsewhere, or a request naming a path relative to a different root, gets a user-visible **Correction:** … The work is not finished appended to an answer that was in fact fine. The agent already has a workspace notion for the shell/file tools — resolving against that instead of os.getcwd() would make the guard match where writes actually land.

🟢 Minor

  • verification.py:1223, 1243, 1273, 1291 — import re is redundant (module already imports it at :21), and import os is repeated in two functions. Hoist os to the module imports and drop the local re imports.
  • file_tools.py:1374 — os.path.splitext(file_pattern)[1] is applied to a value documented as possibly a regex, so foo\.py$ contributes .py$ to the extension set. Harmless but dead; guarding on "looks like a literal name or glob" would keep the set meaningful.
  • file_tools.py:1315-1331 — _looks_binary() opens every candidate a second time before the search reads it. On a large tree that doubles the syscall count; folding the NUL sniff into the first read in search_file() would avoid it.
  • shell_tools.py:1596 — _CD_PREFIX accepts an unquoted directory containing $(...). Harmless today because working_directory is path-validated at :1050-1062 before use, but the validation happening elsewhere is worth a one-line note at the regex so a future refactor doesn't separate them.

Strengths

  • The comments explain decisions, not mechanics, and cite the measurement behind them. DEVELOPER_RUNTIMES, PYTHON_CONSOLE_SCRIPTS and MAX_COMMANDS_PER_10_SECONDS each say what was measured, what was deliberately left out, and why — including the uncomfortable parts (the pytest grant-policy bypass is named rather than hidden). strip_orphan_reasoning()'s docstring warning that the rule is destructive if applied before JSON parsing is the kind of note that prevents a future regression.
  • _looks_binary() is the right fix for the right reason. Replacing the extension allowlist in search_file_content with a NUL sniff fixes a real class of silent wrong answers — grepping a Python project never looked in pyproject.toml — and the docstring correctly argues that any such list is wrong for the next language.
  • The new factory subsystem takes privacy seriously by construction, with three independent defences (output path outside any repo, a refusal to write into a git working tree, and .gitignore anchored so it cannot hide the builder code) plus tests/unit/factory/test_dataset_gitignore.py asserting the anchoring. No shell=True, eval, or pickle anywhere in the 16k new lines.
  • _loop_break_answer() no longer claims success on a stall. Replacing "Task completed with {tool}. No further action needed." with an honest "I stopped … the task is not finished" is a direct fix for the failure mode that is hardest to detect downstream.

…ow a declared sandbox

Argument checking read tokens from shlex.split, which runs in POSIX mode and
treats a backslash as an escape. A Windows-style absolute path tokenised with
its separators removed, stopped looking like a path, and was never handed to
PathValidator -- while the untokenised string is what reaches the shell, so
the file was read. Absolute paths are now recovered from the raw command
string and validated alongside the tokenised operands.

Also adds GAIA_SHELL_SANDBOXED, off unless explicitly set, for callers whose
blast radius really is disposable. It lifts the command-name allowlist and the
operator ban only; path containment is unchanged and asserted in both modes.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes

Two blocking regressions, both introduced by deleting tests first and then removing the code those tests enforced.

🔴 RemoteDisconnected guard deleted. tests/unit/test_remote_disconnected_handling.py is removed along with its AST guard — the enforcement mechanism that kept every except URLError handler from silently dropping a connection reset. The same PR then removes ConnectionError from two urlopen exception handlers in gen_scorecard.py and smoke_test.py. Those changes would have failed test_every_urlerror_handler_also_handles_a_dropped_connection(). http.client.RemoteDisconnected is NOT a URLError subclass (the deleted test_remote_disconnected_is_not_a_urlerror proved that). After this merge, a transient connection drop at either site will propagate a bare ConnectionResetError instead of the expected RuntimeError/handled path — the same class of bug that burned the codebase at #2925, #2927, and #3802.

🔴 Email prescan regression. _honest_prescan_summary in answer_grounding.py no longer qualifies counts when degraded=True. This reverts the user-visible fix for #3768: a scan that skipped a failed mailbox will again read as whole-account coverage. The CHANGELOG entry, the docstring explaining the degraded path, and all four TestHonestPrescanSummary tests are deleted alongside. The log-message change in _mailbox_failure_caveat — now reading "check_suspicious_mail envelope" instead of "scan envelope" — confirms the caveat is intentionally limited to the suspicious-mail path going forward, but that is a regression for pre-scan.

Two more concerns worth resolving before merge:

🟡 Action version downgrades. Nine workflow files downgrade claude-code-action from hash 51db78a… (v1.0.224) to d75b94d… (v1.0.216), and build-installers.yml rolls signpath/github-action-submit-signing-request from v3 back to v2. Pinning by hash is correct practice; a downgrade without an explanation in the PR description is unexplained.

🔍 Technical details

RemoteDisconnected sites left unguarded:

  • hub/agents/email/python/packaging/gen_scorecard.py ~line 568: handler changed from (urllib.error.URLError, ConnectionError, TimeoutError) to urllib.error.URLError alone. Also loses TimeoutError, so a 5-second timeout now propagates uncaught.
  • hub/agents/email/python/packaging/smoke_test.py ~line 581: handler changed from (urllib.error.URLError, ConnectionError, TimeoutError) to (urllib.error.URLError, TimeoutError). Loses only ConnectionError, but the same RemoteDisconnected escape applies.

The deleted test file (tests/unit/test_remote_disconnected_handling.py) had individual behavioural tests for MCP HTTP transport, the Lemonade installer, eval/runner.py, check_doc_links.py, and check_component_core_api.py, as well as the repo-wide AST scan. The AST scan (test_every_urlerror_handler_also_handles_a_dropped_connection) would have caught both of the above removals.

Email prescan regression:

hub/agents/email/python/gaia_agent_email/answer_grounding.py lines ~689–692 (pre-patch):

lead = f"Here's your inbox pre-scan — {summary}. {coverage}."
if envelope.get("degraded"):
    lead += " " + _mailbox_failure_caveat(envelope.get("mailbox_errors"))
return lead

Post-patch: the if block is gone; _mailbox_failure_caveat is still called from _honest_suspicious_summary (unmodified) but no longer from _honest_prescan_summary.

The log-message rename in _mailbox_failure_caveat (line ~522) from "degraded scan envelope" to "degraded check_suspicious_mail envelope" is the tell: the author scoped the function to the suspicious-mail path and removed it from pre-scan. Whether that is intentional or a mistake, #3768 is re-opened.

Action downgrade:
All nine workflow files replace anthropics/claude-code-action@51db78a4b844e144f8d02425cb280435c04a3474 # v1.0.224 with d75b94d5ad426cb8546e6628b6f5f19b84e5cce1 # v1.0.216. .github/workflows/build-installers.yml additionally changes signpath/github-action-submit-signing-request@v3 to @v2.

@kovtcharov

Copy link
Copy Markdown
Contributor

Three of the defects here are also fixed in open PRs of mine, in the same files. Flagging the overlap so we don't land two fixes for the same thing — not to claim priority.

Defect Fixed here Also fixed in
A repeated tool call ends the turn claiming success #3943 #3908
Shell refuses commands it already permits via python -m #3971 #3909
search_file fails on a name the user gave exactly #3941 #3905 (different half — see below)

On search_file the two are complementary rather than duplicate: this PR fixes matching an exact name, mine bounds the walk so a search without a directory can't run past the tool timeout. Both are real; neither subsumes the other.

Two fixes in mine that this PR does not appear to touch — I checked the diff for both:

  • a git -C <dir> sandbox escape, where the path flags are resolved the way git actually resolves them before the allowed-paths check
  • a Windows-only misclassification where a dead server reads to the user as a permissions problem

Worth noting the two harnesses agree from different directions. Yours: refusals 55–76% → 6–10%, tasks 19/27 → 26/27. Mine: 14 tasks × 11 models × 3 GAIA commits, 322 runs scored by real pytest runs and probe scripts, where shell refusals were 17% of all tool calls across 58% of runs and the single largest source of wasted steps. Independent evidence for the same conclusion.

Happy to close mine, rebase them onto this, or split them so each lands once — whichever keeps the tree simplest. Your call.

@kovtcharov

Copy link
Copy Markdown
Contributor

Two things from running the flagship against a benchmark today — one is evidence for a change here, the other is a defect I think would fire false corrections in production.

Your scratch-runner finding reproduces exactly. The comment here says the agent "could not run python -m pytest and had to write a scratch runner". On the feature task, kimi-k2p7-code wrote a file whose entire body was:

import sys
import pytest
sys.exit(pytest.main(["-q", "tests/"]))

then a cleanup.py to delete the files it had written, then a self_destruct.py containing import os; os.remove(__file__). 13 of its 23 tool calls were scaffolding, none of it task work. The self-destruct script was never executed, so it and cleanup.py are still in the workspace — and the agent's answer claimed the cleanup was done. Independent support for the allowlist change, and for the step cost being larger than it looks.

The unwritten-claims guard resolves against the wrong root. unwritten_claims(answer, os.getcwd()) is called at three sites. On a sidecar that is the package directory, not the user's project — GaiaAgentConfig records this as measured, in the comment on allowed_paths:

ChatAgent defaults this to [Path.cwd()], which is wrong for a sidecar: the daemon launches it with cwd = the package directory, so the agent ends up sandboxed to its own source tree.

So the agent writes report.csv into the project, the guard looks for it under the package dir, finds nothing, and tells the user the file was not written. That is a false correction on a correct run, and it lands in the one place the user is being told to distrust the agent.

Two open PRs widen the gap further: #3902 moves script execution to the resolved project root, and #3907 moves scratch writes to a dedicated scratch dir. After either lands, cwd is no longer where written files live even outside a sidecar.

Suggested: resolve against _project_map_root(), falling back to the scratch dir and allowed_paths[0], and treat "cannot determine a root" as "do not claim the file is missing" — the guard should stay silent rather than assert something false.

🔍 Technical details

Call sites in the current head of this PR (from the PR files API — gh pr diff 406s on this PR, the diff is over the 20k-line cap):

missing = unwritten_claims(answer, os.getcwd())
... unwritten_claims(getattr(self, "_current_query", "") or "", os.getcwd())   # ×2

GaiaAgentConfig.project_root's own docstring notes it "stays None when neither is a repository, which is the common case for a sidecar launched from its package dir" — so a project_root or os.getcwd() fallback reproduces the same bug. The scratch dir from #3907 is the more reliable anchor for agent-authored files, with the project root for files the task named explicitly.

Worth a test that the guard stays silent when the root cannot be resolved, given this PR's own history is a guard that never executed because its failure was swallowed.

…timeout

Adds GAIA_SHELL_SANDBOXED (off unless set) so a caller whose blast radius is
genuinely disposable can declare it. Lifts the command-name allowlist and the
operator ban only; path containment is unchanged and asserted in both modes.
Measured against 28,064 recorded agent shell commands, 94% use an operator the
default rules refuse, so a comparison against an agent run with permissions
bypassed was not measuring what it claimed to.

Also fixes a pre-existing path-validation bypass: argument checking read
tokens from shlex.split in POSIX mode, which strips backslashes, so a Windows
absolute path stopped looking like a path and was never validated -- while the
untokenised string is what reaches the shell. Confirmed reading a canary
outside the workspace with the allowlist fully enabled.

And enforces the harness task timeout against the whole process tree;
subprocess.run(timeout=) left one task running 92 minutes against a 900s
limit, recorded as an agent failure.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

The core agent improvements — unlimited steps by default, idle-turn/repeat-call/missing-output guards, binary-sniff search, shell sandbox expansion — are well-designed and clearly motivated. Three findings below; the first two are worth resolving before merge.


🟡 Email: #3768 fix is silently reverted

_honest_prescan_summary no longer adds the mailbox-failure caveat for degraded scans. Before this PR a degraded envelope (e.g. Outlook token expired) produced "Here's your inbox pre-scan — 2 urgent. [Gmail counts only]. Outlook couldn't be scanned…"; after it produces "Here's your inbox pre-scan — 2 urgent." — indistinguishable from whole-account coverage. The CHANGELOG entry for #3768 was deleted and the full TestHonestPrescanSummary test class removed, so there is no safety net if someone re-adds the code path in a future PR.

If the fallback is now unreachable for degraded scans (perhaps the model's own framing always passes grounding for degraded envelopes), a one-sentence comment in _honest_prescan_summary is all that's needed to prevent re-introduction. Without it this reads as a behaviour regression.


🟡 OAuth polling path: raw response body now in error_description

In connectors/flow.py, the device-code polling else branch now sets error_description=err_payload.get("error_description", resp.text[:300]). The original _structured_oauth_error helper was built specifically to avoid this (#3875): device-code and token-exchange requests carry credentials and PKCE verifiers, and providers sometimes echo them back in error bodies. The comment documenting that rationale was removed.

If classify_oauth_exception genuinely needs the provider text to route AADSTS65001 errors, pass it as a separate field on OAuthProviderError that the classifier reads but that is never forwarded to the user or written to a structured log. Threading it through error_description risks surfacing provider-chosen content to users or sinks that weren't designed to handle it.


🟢 _nudged_* flags not reset between queries

_nudged_idle_turn, _nudged_repeat_loop, and _nudged_missing_output are set on self inside the loop but never cleared when process_query is called again. In a multi-turn session with one agent instance the idle-turn guard fires at most once across the whole conversation. If one-nudge-per-agent-lifetime is the design intent a comment makes that explicit; if the intent is one-nudge-per-query each flag should be reset at the top of process_query.

🔍 Technical details

Email regression

  • hub/agents/email/python/gaia_agent_email/answer_grounding.py:553 — _honest_prescan_summary removed the if envelope.get("degraded") block that called _mailbox_failure_caveat
  • hub/agents/email/python/CHANGELOG.md — the fix(email): a pre-scan that skipped a failed mailbox reports its counts as complete #3768 entry ("A triage summary no longer reads as complete when one mailbox failed…") deleted
  • hub/agents/email/python/tests/test_answer_grounding.py — TestHonestPrescanSummary class (four tests, all testing degraded behaviour) deleted

OAuth raw body

  • src/gaia/connectors/flow.py:580 (device-code polling path) — error_description=err_payload.get("error_description", resp.text[:300])
  • Same file also changes the code-exchange path in a similar direction; check whether _exchange_code_for_tokens has the same fallback

Nudge flag reset

  • src/gaia/agents/base/agent.py:6603 — self._nudged_repeat_loop = True
  • src/gaia/agents/base/agent.py:7191 — self._nudged_idle_turn = True
  • src/gaia/agents/base/agent.py:7228 — self._nudged_missing_output = True
  • None are cleared in process_query around line 5168 where steps_taken = 0 and final_answer = None are reset

Ovtcharov added 3 commits September 17, 2026 16:50
…-fixes

# Conflicts:
#	docs/releases/v0.15.3.mdx
#	src/gaia/agents/base/agent.py
#	tests/unit/agents/test_agent_source_invariants.py
#	tests/unit/agents/test_loop_break_truthful.py
#	tests/unit/agents/test_verification_scope.py
…ed budget

NO_STEP_LIMIT is 0, so 'steps_taken < steps_limit - 1' reads as
'steps_taken < -1' and is False forever. DEFAULT_MAX_STEPS is also 0, so on
the default budget the guard added in #3750's follow-up could not fire at
all. The eight other step comparisons in the same loop already carry the
'steps_limit == NO_STEP_LIMIT or ...' guard; this one was missed.

Caught by the source invariant in test_default_max_steps.py, which scans for
unguarded step comparisons -- the same silent-no-op shape as a guard whose
helper was never imported.
…ed budget

NO_STEP_LIMIT is 0, so 'steps_taken < steps_limit - 1' reads as
'steps_taken < -1' and is False forever. DEFAULT_MAX_STEPS is also 0, so on
the default budget this guard could not fire at all. The eight other step
comparisons in the same loop already carry the
'steps_limit == NO_STEP_LIMIT or ...' guard; this one was missed.

Caught by the source invariant in test_default_max_steps.py, which scans for
unguarded step comparisons -- the same silent-no-op shape as a guard whose
helper was never imported.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes — two reversions need attention before merge.


🔴 OAuth error handling re-exposes raw provider bodies, reversing the #3875 fix.

_structured_oauth_error was deleted. Its whole purpose — documented explicitly — was that OAuth error responses MUST NOT fall back to raw body text because "providers echo request context back into error bodies" (the token exchange sends the auth code and PKCE verifier). The replacement uses response.text[:300] as the error_description fallback in _exchange_code_for_tokens, and resp.text[:300] directly in the start_device_flow error message. Both were specifically prohibited by #3875. If a provider echoes the authorization code or verifier into its error body, it now reaches user-visible errors and logs.

The fix is to replicate _structured_oauth_error's sentinel: return "" when error_description is absent from the JSON, never the raw body.


🔴 The #3768 degraded-scan fix is reversed without explanation.

_honest_prescan_summary no longer appends the mailbox-failure caveat on a degraded scan. All four tests that enforced the invariant ("Outlook couldn't be scanned", "Part of your mail could not be scanned") are deleted, and the CHANGELOG entry for #3768 is removed. A user whose Outlook token expired will again receive a confident pre-scan count with no indication that a mailbox was silently skipped — the exact failure #3768 fixed. If this revert is intentional, the PR should say why and what replaces the protection.


🟡 ConnectionError removed from MCP exception handlers; RemoteDisconnected will now escape.

Multiple except (urllib.error.URLError, ConnectionError, TimeoutError) clauses in cli.py and gen_scorecard.py are narrowed to except urllib.error.URLError. The original comments correctly noted that http.client.RemoteDisconnected (a ConnectionError) escapes URLError via the path documented in AbstractHTTPHandler.do_open. Removing it turns a graceful "server not accessible" message into an unhandled traceback when the MCP server drops the connection mid-request.

🔍 Technical details

flow.py — token exchange fallback (_exchange_code_for_tokens)

# New — re-exposes raw body as fallback
error_description=err_payload.get("error_description", response.text[:300]),

Old _structured_oauth_error returned ("", "") when the field was absent, never the raw body. The replacement must do the same:

error_description=err_payload.get("error_description", ""),

flow.py — device flow start (start_device_flow)

# New — raw body in user-visible error
f"{resp.status_code}: {resp.text[:300]}. Check the client id "

Old code extracted only error/error_description fields. Restore that pattern or use an empty fallback.

answer_grounding.py:689 — The removed block:

if envelope.get("degraded"):
    lead += " " + _mailbox_failure_caveat(envelope.get("mailbox_errors"))
return lead

was the protection for #3768. The deleted TestHonestPrescanSummary tests enforced it.

cli.py:7343, 7756, 7819, 7949 and gen_scorecard.py:555 — narrow except clauses miss ConnectionResetError → RemoteDisconnected. If intentional, confirm that the Python version in use wraps these in URLError; the original comment says it does not.

Action downgrade note: All anthropics/claude-code-action pins move from v1.0.224 → v1.0.216 (8 versions back) and signpath/github-action-submit-signing-request from v3 → v2. Both are downgrades — worth confirming these are deliberate rollbacks to a stable version and not accidental.

@kovtcharov

Copy link
Copy Markdown
Contributor

Two blockers found while merging this into a test build. Both are verified against this PR's current head.

1. This PR would silently revert 8 fixes that are already on main. Its first commit was built from an older copy of the tree, so for eight merged PRs it deletes every line they added: #3818, #3877, #3869, #3780, #3857, #3761, #3759, #3760. That includes #3877, the fix that stops OAuth flows leaking provider response bodies. Merging as-is would bring that back without anyone noticing. The fix is to rebuild the branch on current main so it carries only its own changes.

2. The shell now refuses any relative path containing a slash. sub/f.txt is read as the absolute path /f.txt and rejected, so ordinary commands inside the workspace fail, e.g. cat sub/file.txt and python -m pytest tests/unit:

Access denied: Argument '/f.txt' resolves to forbidden path '/f.txt'

It fails with the sandbox switch both off and on.

🔍 Technical details

Reverts. For each commit on main I compared the lines it added with the lines this PR removes relative to its merge-base (07985393). All eight PRs are ancestors of that base, so none of these removals are the PR's own work:

PR on main lines it added removed by this PR
#3818 RemoteDisconnected handling 196 196
#3877 OAuth response-body leak 141 141
#3869 daemon --dev-src-dir errors 80 80
#3780 email pre-scan summary 61 61
#3760 agent-ui deps bump 88 88
#3759 root npm deps bump 10 10
#3857 reject tolerated test failures 4 4
#3761 github-actions bump 2 2

Control: #3867 is also in the base and touches none of these files; this PR removes 0 of its 55 lines.

Relative paths. Reproduced with tests/unit/agents/test_sandboxed_shell.py::TestContainmentIsNotRelaxed::test_a_relative_path_inside_the_workspace_is_not_read_as_absolute, parametrized over the sandbox flag 0 and 1. It creates sub/f.txt in the workspace and runs a command on it; both cases fail with the error above. A fix is on branch claude/gaia-latest-capabilities as 7b0ae551 ("stop refusing relative paths that contain a slash"), with that test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::email Email agent changes agents cli CLI changes daemon Daemon supervisor / sidecar control plane dependencies Dependency updates devops DevOps/infrastructure changes documentation Documentation changes eval Evaluation framework changes llm LLM backend changes mcp MCP integration changes performance Performance-critical changes sidecar Agent sidecar contract / harness tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants