Skip to content

fix(agent): allow remaining actions for an approved task - #6113

Merged
StressTestor merged 19 commits into
odysseus-dev:devfrom
RaresKeY:fix/agent-task-approval-continuation
Aug 19, 2026
Merged

fix(agent): allow remaining actions for an approved task#6113
StressTestor merged 19 commits into
odysseus-dev:devfrom
RaresKeY:fix/agent-task-approval-continuation

Conversation

@RaresKeY

@RaresKeY RaresKeY commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Fix the repeated exact-approval loop from #6112 while keeping approval scope explicit and temporary. Approval continuation is treated as control-plane input rather than a new user turn: it reuses the interrupted request's bounded, digest-bound continuation query, memory/RAG/skills context, and server-selected tool candidates without persisting or rendering a synthetic approval message. The approval card now offers Allow for this task and Allow for this chat session alongside Deny.

The choices are:

  • Allow for this task — execute the sealed first action, restore the original context and selected candidates, and bypass only the automatic post-external-context gate for later actions in the resumed in-memory task;
  • Allow for this chat session — execute the sealed first action, restore the same context, and bypass only that gate for later requests in this chat session;
  • Deny — resolve the approval control without executing the sealed action.

Task scope does not carry into a new ordinary user turn. Chat-session scope is limited to the same server-owned chat session and does carry to later requests in that chat; a different chat or fork does not inherit it. Current disabled-tool rules, owner/admin checks, document freshness, workspace confinement, dispatcher policy, and any process sandbox remain authoritative.

Target branch

  • This PR targets dev, not main. All PRs land in dev; main is curated by the maintainer at each release.

Linked Issue

Fixes #6112

Part of #5815

Related: #6083 / #6084, #6092 / #5819, and #6103 / #6104.

This is an independent current-dev bug fix prepared against 43682d4. It does not depend on #5818, #6084, #5819, or #6104. #6084 is an overlapping global-disable alternative; both should not be treated as cumulative product behavior without a maintainer decision.

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)
  • New feature (non-breaking — adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev.
  • My changes are limited to the approval continuation contract, approval-card routing and keyboard-shortcut wiring, focused cache-busting, and regression coverage.
  • I actually ran the app (docker compose up or uvicorn app:app) and verified the change works end-to-end.
  • I did not run the app/runtime validation during patch preparation; the gap is stated below.

Behavior contract

  • The card exposes Allow for this task, Allow for this chat session, and Deny.
  • Both allow choices execute only the opaque sealed first action.
  • The original continuation query is bounded, server-derived, digest-bound, and omitted from the public card payload.
  • The original server-selected candidate set is bounded, normalized, digest-bound, and omitted from the public card payload.
  • The approval control submission is not persisted as a user message or rendered as a synthetic chat bubble.
  • Memory/RAG/skills context and active-document routing rebuild from the original query.
  • Allow for this task bypasses only the automatic gate for the rest of the resumed in-memory task.
  • Allow for this chat session bypasses only that gate for later requests in the same chat session.
  • A new ordinary user turn re-arms the gate after task-scoped approval; chat-session approval remains limited to the original session and does not transfer to a fork.
  • Current policy and execution restrictions are still checked.
  • No global setting or Full Access behavior is added.

How to Test

  1. Run focused regressions:
python -m pytest -q tests/test_tool_approval_task_scope.py tests/test_tool_approvals.py tests/test_external_context_tool_gate.py tests/test_tool_policy.py tests/test_chat_route_tool_policy.py
node --check static/js/chat.js
python -m compileall -q src routes tests
git diff --check
  1. Run the full repository suite:
python -m pytest -q
  1. In a running app, use Agent mode with web plus memory/skills or file/shell tooling enabled, then trigger external context followed by a gated action. Runtime validation was not performed during preparation.

  2. Choose Allow for this task. Confirm the sealed action executes, the original memory/RAG/skills context and selected tools remain available, later gated actions in the same continued task do not show another approval card, and a new ordinary message re-arms the gate.

  3. Repeat in the same chat and choose Allow for this chat session. Confirm the sealed action executes and later otherwise-gated requests in that chat do not show another card at this gate, while a different chat or fork still asks.

  4. Choose Deny and confirm the control resolves without executing the sealed action.

  5. Disable Bash/web or use a non-admin account and confirm neither scope choice overrides those restrictions.

Visual / UI changes — REQUIRED if you touched anything that renders

This replaces the former one-shot choice with two explicit continuation-scope choices and adds numeric 1–3 shortcuts through the existing ask-user option handling. The existing card/options renderer is reused; no new CSS or component styling is introduced.

  • Screenshot or short clip of the approval-card rendering is attached below.
  • Style match: the existing card/options renderer is reused; no color, typography, spacing, icon, or component pattern is introduced.
  • No new component patterns. The existing ask-user card and option path are extended.
  • I am not submitting a bulk-generated PR. This is a focused user-directed fix for the existing issue.

Screenshots / clips

The attached images document the approval-card layout. They are visual evidence only; end-to-end model/tool behavior and mobile-specific wrapping were not validated during preparation.

image image

Preparation-environment validation

  • Focused approval/gate behavior passed in an isolated harness.
  • Python and JavaScript syntax checks passed for the changed logic; compileall and diff checks are included above.
  • Live app/model/tool validation was not performed during preparation.

@github-actions github-actions Bot added needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap needs visual evidence UI-sensitive change without an attested screenshot or clip from the running app labels Aug 19, 2026
@github-actions github-actions Bot removed needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap needs visual evidence UI-sensitive change without an attested screenshot or clip from the running app labels Aug 19, 2026
@RaresKeY

Copy link
Copy Markdown
Member Author

Note: This PR is a bundled fix for current agent gate, separately it is pending removal and refactor, see #5818 and related
As a narrower fix for current dev, it should land first before heavy refactor

@RaresKeY
RaresKeY marked this pull request as ready for review August 19, 2026 05:52
@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Aug 19, 2026

@StressTestor StressTestor 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.

reviewed at fb3a401. the mechanism holds and i'd merge it, with two things below, one pre-existing and one that wants a maintainer call before it ships wider.

what i verified: approve_task is minted only by consume() on an owner+session-matched pending (tool_approvals.py:400-430, gated again at chat_routes.py:1146). it sets allow_remaining_actions, which becomes a per-run approval_gate_bypassed on the security context (agent_loop.py:3455-3467). nothing else reads or persists that flag, so a new user turn or a step-limit continue builds a fresh context and re-arms the gate. current tool policy, ownership, workspace confinement, and sandbox checks still run on every call (tool_execution.py:697-710). deny consumes and records resolution before returning with no model call (chat_routes.py:1177). 369 tests across the chat/agent/tool files plus the five focused suites the PR documents (test_tool_approval_task_scope, test_tool_approvals, test_external_context_tool_gate, test_tool_policy, test_chat_route_tool_policy) pass here; node --check, compileall, diff --check clean.

one thing to be aware of on "allow for this task": decision_for returns True before it inspects the tool (tool_capabilities.py:631-632), so untrusted content fetched after the click is ungated for the rest of that in-memory run. that's the deal the label makes and i'm fine with it. one line in the specs branch next to the gate contract would stop anyone reading "for this task" as "for this one sealed action".

continuation_query isn't as sealed as PR #6113's description implies. it's correctly absent from public_payload, but on the continuation turn message = pending_tool_approval.continuation_query (chat_routes.py:1184), which reaches the owner-gated stream_status query field (chat_routes.py:1594) and the chat.completed webhook's user_message (chat_helpers.py:1256). neither is behind the tool_approval_continuation gate. it's the owner's own query going to the owner's own webhook, so severity is low, but pre-PR that webhook fired the synthetic "Approved the exact..." string and now it re-emits the real query. worth making that a decision.

pre-existing, since this PR builds more on it

the resumed path appends the sealed result as assistant(content="") then user(result) (agent_loop.py:4738, via _append_tool_results(messages, "", ...)). Anthropic's API rejects a non-final assistant message with empty content, and i reproduced that payload shape locally. this isn't new in #6113: base 032967a already emits the same empty assistant turn on plain approve-once, so the PR inherits it and extends it to approve_task. the one-line fix is content=None instead of "" for the no-prose turn, the same thing the native-tool-call branch already does for Gemini/Ollama. openai-compat and local endpoints take the empty string, so this only bites a native-claude endpoint in agent mode. fine as a follow-up.

small

  • chatStream.js:18-38 intercepts the synthetic approval click for 60s, but the retry loop that waits out an in-flight stream (chat.js:65-79) has no deadline. if streaming outlasts the window the click lands on the send button in newchat mode with an empty composer and opens a new chat (app.js:4034). calling chatForm.requestSubmit() from chat.js instead of .click() drops the interceptor and the race.
  • the PR body still says the synthetic approval line "stays in transcript history but is excluded from the resumed model prompt". at fb3a401 nothing is persisted (chat_routes.py:1350, persist_user_message=not tool_approval_continuation) and the bubble is hidden. drop that sentence.

@github-actions github-actions Bot added needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap ready for review Description complete — ready for maintainer review and removed ready for review Description complete — ready for maintainer review needs runtime validation Runtime validation not attested — tick the app-run box after running it, or state the gap labels Aug 19, 2026
@RaresKeY

Copy link
Copy Markdown
Member Author

o3LL added 3 commits August 19, 2026 15:03
The chat card now reuses the wire value `approve` to mean chat-session
scope, and `consume()` returned `allow_remaining_actions=True` for it
unconditionally. The skill-test approval route was never updated: it still
sends `approve` meaning "once", and its button still reads "Allow once",
but the grant it got back set `approval_gate_bypassed` for the rest of the
resumed run. That surface wraps the skill body and every transcript byte
as untrusted context, so it is the last place where one click should
ungate everything that follows.

Give `consume()` an explicit `allow_continuation` flag. Callers that own a
resumable chat keep the scope the user picked; callers that do not — the
skill tester, unattended audits — get SINGLE_ACTION and the gate re-arms
behind the sealed action, which is what their label promises.
chatStream.js, compare/index.js and compare/stream.js all changed
behaviour but kept their old `?v=`, while chat.js and chatRenderer.js were
bumped. A returning browser therefore serves the new chat.js — which now
deliberately leaves the composer empty and clicks the send button — next to
the cached chatStream.js that has no interceptor. With an empty composer
that button sits at `data-mode="newchat"`, so the click opens a new chat
and the approval is dropped.

Bump the three, and version compare/stream.js's chatRenderer import to
match everyone else's so the ask_user keydown listener binds to one module
instance instead of two.
With an approval card on screen and focus anywhere outside an input, a bare
`1` fired `approve_task` — the widest of the three grants — with no
modifier and no confirmation. That card is the one control whose entire
purpose is deliberate consent after untrusted context influenced the run,
and Deny sits at 3.

Label the card with its kind and skip the shortcut for approvals. Ordinary
ask_user questions keep 1-3.
o3LL added 2 commits August 19, 2026 15:05
…choice

renderAskUserCard removes the card as soon as onSubmit accepts, but the
resume loop gave up silently after 10s if the originating stream still owned
the pane. The user saw the click land, the card vanish, and nothing happen,
with no way to get it back.

Re-render the card on that deadline and say why. The reroll case still
returns without sending — that choice belongs to a stream that no longer
exists.
`if decision != "deny"` is always true — the deny path returns a
StreamingResponse a few lines above. It reads as if deny still falls
through to the toggle restore.

@o3LL o3LL left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pushed five commits on d98da547, one per issue so any can be reverted on its own.

8afb8bff: the skill-test route still sends approve meaning "once" and its button still reads Allow once, but now that approve means chat-session scope that grant set approval_gate_bypassed for the whole resumed run on the surface that wraps the skill body and transcript as untrusted context. consume() takes an explicit allow_continuation; callers without a resumable chat get a single-action grant and the gate re-arms behind the sealed action.
0f5c6f05: chatStream.js and the two compare modules changed but kept their old ?v=. A returning browser gets the new chat.js, which leaves the composer empty and clicks send, next to the cached chatStream.js that has no interceptor; with an empty composer that button sits at data-mode="newchat", so the click opens a new chat and the approval is dropped. Also versioned compare's chatRenderer import so the keydown listener binds to one instance.
8f26110c: with an approval card up and focus outside an input, a bare 1 fired approve_task; Deny is 3. The shortcut now skips approval cards, plain questions keep 1- 3.
abc674b3: a compare pane removed the card on click but returned silently after 10s if the origin stream still owned it, losing the choice. It re-renders the card instead. The reroll case still drops, which is right.
978a686e: if decision != "deny" is unreachable; deny returns above it.

Seven new tests. Full suite locally: 5763 passed, 4 failed, the four also fail on unmodified dev. Not verified: the real model path to a gated action after untrusted context, mobile, Docker.

One left open as your call: the chat-session grant is rebuilt from persisted transcript metadata, so it re-arms on every later turn in that chat, survives a restart, and has no revocation once the card is resolved. Is "forever in this thread" the intent, or should it expire?

Otherwise, looks good to me, approved! I wont merge since I'm the last one to have pushed on this PR.

@StressTestor StressTestor 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.

re-reviewed at 978a686, merging.

session scope is the part i wanted certainty on and it holds. the grant marker lives in server-owned message metadata the model never writes to, and AskUserTool builds its payload from an allowlist of question/options/multi, so a tool result can't smuggle in kind/resolved/session_id. probed it directly: a forged ask_user event grants nothing, the grant doesn't read across sessions, and approve_task and deny both confer nothing. 327 pass locally across the approval, gate, policy and compare suites.

two follow-ups, neither blocking. consume()'s allow_continuation defaults to True (tool_approvals.py:433), so a caller that never passes it gets continuation scope for free and skills_routes has to opt out by hand; the safer polarity is the other way round. and the interceptor race from my last pass is still live, chat.js:79 clicks while chatStream.js:38 stops intercepting at 60s, so a stream that outruns the window lands that click on a new chat.

@StressTestor
StressTestor merged commit 9816523 into odysseus-dev:dev Aug 19, 2026
19 checks passed
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 19, 2026
…val JS

index.html modulepreloaded chat.js under the pre-odysseus-dev#6113 buster while the
script tag used the new one; and sw.js CACHE_NAME was never bumped, so
clients kept serving the cached old HTML shell (stale-while-revalidate)
and re-running old chat.js that sent approval clicks as literal text.
Bump both so the approval card sends control fields instead.
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 19, 2026
odysseus-dev#6113 bumped app.js's internal imports of chat.js/chatRenderer.js to the
new buster but left app.js's OWN reference in index.html at the old
20260815toolapproval4 buster. Browsers therefore served the cached
pre-odysseus-dev#6113 app.js, whose imports pulled the old chat.js/chatRenderer.js —
the old click handler sends approval as literal text instead of control
fields, so 'Allow for this chat session' re-sealed the gate every turn.
Bump app.js (modulepreload + script tag) and the SW CACHE_NAME so the
fresh module graph is fetched.
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 20, 2026
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 20, 2026
…val JS

index.html modulepreloaded chat.js under the pre-odysseus-dev#6113 buster while the
script tag used the new one; and sw.js CACHE_NAME was never bumped, so
clients kept serving the cached old HTML shell (stale-while-revalidate)
and re-running old chat.js that sent approval clicks as literal text.
Bump both so the approval card sends control fields instead.
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 20, 2026
odysseus-dev#6113 bumped app.js's internal imports of chat.js/chatRenderer.js to the
new buster but left app.js's OWN reference in index.html at the old
20260815toolapproval4 buster. Browsers therefore served the cached
pre-odysseus-dev#6113 app.js, whose imports pulled the old chat.js/chatRenderer.js —
the old click handler sends approval as literal text instead of control
fields, so 'Allow for this chat session' re-sealed the gate every turn.
Bump app.js (modulepreload + script tag) and the SW CACHE_NAME so the
fresh module graph is fetched.
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 20, 2026
odysseus-dev#6113 left app.js's own cache-buster stale (its internal imports were bumped
but index.html still references app.js at 20260815toolapproval4). Our local
bump of app.js + service-worker CACHE_NAME fixed the approval-click loop but
broke upstream's regression test (test_frontend_tool_approval_uses_opaque_id_
and_fixed_decisions asserts the old buster appears exactly 2x). Revert to
match upstream/dev; workaround is a hard refresh after deploy, and the real
fix belongs upstream.
touzenesmy pushed a commit to touzenesmy/odysseus that referenced this pull request Aug 22, 2026
Rewrite the orientation doc to reflect the current Improvements branch
state: frontend static assets and cache-buster behavior, tool approval
gate history (upstream odysseus-dev#6113), and consolidated lessons learned from
2026-08-18/19. No code changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approve once prompt conflict with RAG tools

3 participants