Skip to content

Release v0.6.0 — jobs, durable sessions, security hardening, and framework correctness - #112

Merged
shoom1 merged 158 commits into
mainfrom
develop
Aug 4, 2026
Merged

Release v0.6.0 — jobs, durable sessions, security hardening, and framework correctness#112
shoom1 merged 158 commits into
mainfrom
develop

Conversation

@shoom1

@shoom1 shoom1 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Release v0.6.0developmain. 158 commits (129 non-merge) since v0.5.3, 194 files, +29,511 / −3,428.

The headline is not a single feature: it is that long-running work, conversation state, and the sandbox all became durable and hardened at once, and that a framework-wide correctness pass closed a long tail of identity, lifecycle and cross-process defects. Five P0 security fixes ship here that are not in v0.5.3.

Major features

  • Long-running jobs, end to end. An internal JobManager over pluggable execution backends (subprocess, inprocess), restart-safe completion via an on-disk exit_code sentinel, a concurrency cap and queue, a /jobs command, and a background JobMonitor that advances detached jobs with no LLM turn. When a job that opted in finishes, the agent is auto-resumed with its result (job_auto_resume, default off; /resume on demand).
  • Durable sessions by default. Conversations persist continuously through each orchestrator's native store — ADK via DatabaseSessionService, LangGraph via a persistent checkpointer — keyed by session id. session_store selects sqlite (default), postgres, or memory. --session <id> resumes; /sessions lists and deletes. Persistence is per-event and full-fidelity, so it survives a crash mid-turn and keeps function-call ids intact.
  • Docker sandbox backend for sandbox_execute, with a dedicated CI job that fails rather than skips when a runtime is missing.
  • Stateful executor toolset and the report-writer skill, including compile_document for LaTeX output.
  • Release acceptance coverage — headless application composition, a real-terminal PTY smoke, and a built-wheel job that installs the artifact and drives the installed research-demo console script.

Security

Five P0 fixes plus the fail-closed permission hardening:

  • P0-1 — project config can no longer flip security boundaries. A cloned repo's ./.{app}/settings.json (and a cwd-relative .env) is restricted to an explicit deny-by-default allowlist; security-sensitive keys are dropped with a warning. Interactive "Allow always" grants moved out of the repository to ~/.{app}/project_grants.json, keyed by resolved project path.
  • P0-2 — sandbox file transfer is symlink-safe. Staging and collection refuse to follow symlinks (copy_regular_file_no_follow), outputs are 0700. O_NOFOLLOW guards only the final component, so the parent directory is lstated too.
  • P0-3 — compile_document host execution is contained. Private temp build dir, exact TEXMF allowlist, anchored source filename, RLIMIT_FSIZE/RLIMIT_CPU, bounded captured output, SIGKILL on timeout.
  • P0-4 — glob/grep are contained to the authorized root. Patterns cannot escape it, ceilings apply before the walk, and truncation is reported instead of silently returning a partial answer.
  • P0-5 — web_fetch cannot reach internal networks. A PinnedTransport resolves every A/AAAA record up front, rejects if any is non-global (including NAT64 and 6to4-relay forms), then connects to the validated IP with sni_hostname so TLS still binds to the hostname — closing the DNS-rebinding window. Redirects and robots.txt use the same transport; bodies are capped while streaming.
  • P1-7 — the permission engine fails closed. A missing engine denied nothing; it now denies. Tool identity is resolved by the object the registry issued, never by name — an unregistered callable named after a genuine tool no longer inherits its capabilities.

ADK multi-agent delegation repaired

Two defects made ADK's built-in agent hand-off unusable, so any app declaring sub_agents could not delegate at all.

  1. PermissionPlugin unwraps .func only for exact ADK function-tool types, and ADK's auto-injected TransferToAgentTool is a FunctionTool subclass — so the routing tool resolved to no registry identity and was denied as unregistered, even with permissions disabled. Its exact class is now trusted on the same terms as the others; every other subclass, forged object and same-named tool stays denied, and no name-based authority was restored.
  2. ADK builds the tool's model-visible description from transfer_to_agent's docstring, which through 1.37.0 advises callers to "use TransferToAgentTool instead of this function directly" — guidance meant for Python callers that led models to emit the class name, which ADK then rejects. A narrowly scoped before-model plugin corrects that description on the prepared request, only for the exact native tool and only while the misleading text is present. It is idempotent and becomes a no-op on any ADK release shipping a corrected docstring (fixed upstream in 2.x).

Breaking changes and migration

Change Migration
ToolRegistry.register() raises on duplicate names Two tools sharing a name must use declare_tool + register_tool(..., variant_of=...); previously the last import silently won
Tools in a non-default ToolRegistry are not framework-issued They pass through assembly untouched and are denied by the permission engine — register framework tools in the default registry
Session hooks take user_id session_exists/list_sessions/delete_session/recent_messages/load_session/save_session gain an optional keyword; overrides that never added it keep working
Backends without a durable store supports_sessions is False and base hooks raise NotImplementedError instead of answering False/[]
MessageProcessor.process() returns TurnResult Switch on TurnResult.status; CANCELLED and FAILED were previously indistinguishable
Agent graphs validated up front Duplicate names, dangling/self sub_agents, cycles, shared children and more than one root raise AgentGraphError
Prompt factories Zero args or exactly one settings arg; async def and non-string returns are rejected
skill_scripts_enabled removed run_skill_script is exposed exactly when make_skill_toolset is given a code executor
BaseWorkflowManager._TOOL_SERVICE_MAP removed Use @register_tool(..., requires="kb_manager")
Legacy JSON session snapshots removed Superseded by the native durable stores; no migration of old JSON sessions
JobRecord.resumed Now a read-only property; the field is resume_state (pending → resuming → delivered|failed)
ALLOW_ALWAYS_CHOICE value changed Now "Allow always for this project"; the former response wording is still parsed, so no behavioural change

BaseSettings(google_api_key=...) now actually binds — it silently bound nothing before, so workarounds can be simplified, and a misspelled credential kwarg is now an error rather than being dropped.

Verification

Deterministic (offline): the full offline suite is green — 2415 passed, 9 skipped, 34 deselected, 26 xfailed. compileall, pip check and git diff --check clean at the tip and per commit.

PTY: the real console process is driven over a pty through startup → /help/exit, asserting no traceback and exit 0, with no API key, network, Docker or LLM. The session is also asserted side-effect free.

Built wheel: a dedicated CI job builds the wheel, installs it into a throwaway virtualenv and runs the same session through the installed research-demo console script, confirming the child resolves inside the venv (not the checkout), that the packaged data files ship (benchmarks.csv, the report-writer SKILL.md, report_template.tex), and that the declared version reaches the wheel filename, the installed distribution metadata and agentic_cli.__version__. The version is derived from pyproject.toml, not hardcoded.

Docker: the isolation job runs against a real container runtime and fails rather than skips when one is missing.

Live research demo (-m llm, ADK, gemini-2.5-flash — the shipped default, isolated from developer config): three independent runs, no retries, all three behavioural contracts green in each — explicit planning, bounded arXiv delegation with a successful search_arxiv, and KB ingest → readback over a persistent session. gemini-3.1-pro-preview — the model that originally emitted the class name — also passes delegation; it is not the default and nothing is pinned to it.

Known limitations

  • Job-result resume is ADK-only. The coordinator and association layer are backend-agnostic, but resume_with_job_result is implemented for ADK; LangGraph resume is not wired.
  • The non-CLI backend-exception contract is unresolved. A model that invents a tool name surfaces as an exception out of process(). The CLI catches it and renders a workflow error, but every non-CLI consumer inherits an undocumented contract. Whether process() should propagate backend exceptions or convert them to fatal WorkflowEvent.ERROR events is a deliberate post-0.6 decision.
  • LangGraph is in maintenance. It remains supported and tested, but new capability work (model provisioning, job resume) is ADK-first.
  • Third-party job backends are polled by every observing manager, and only an UNKNOWN poll answer is filtered — a backend returning a wrong non-UNKNOWN state is still trusted.
  • InProcessBackend.close() lets queued work finish, so a long backlog delays interpreter exit rather than being silently discarded.

Publication is manual

There is no publish workflow in this repository — CI runs tests only. Merging this PR and tagging does not build or upload a package anywhere. Building and publishing the 0.6.0 artifact to any index is a separate, manual step, and tagging should follow the existing convention (tag on main after the merge).

https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh

shoom1 added 30 commits June 14, 2026 14:27
Typed long-running tools start detached work via an internal JobManager over
pluggable execution backends behind one JobBackend interface. The LLM only ever
sees the tool; JobManager is internal infrastructure (never an LLM tool) and
there is no generic job_submit.

- tools/jobs/backends.py: JobBackend + JobState; SubprocessBackend (detached,
  start_new_session, restart-safe on-disk exit_code sentinel via subshell, zombie
  reaping with returncode fallback) and InProcessBackend (thread pool).
- tools/jobs/manager.py: JobManager + JobRecord — persistence under
  ~/.{app_name}/jobs/, startup reconciliation, concurrency cap+queue.
- tools/jobs/tools.py: reference run_shell_job (long_running=True,
  longrunning.run_shell_job -> default user-verify) + observe-only
  job_status/job_result/job_logs/job_cancel/job_list (jobs.manage).
- registry: @register_tool(long_running=) flag on ToolDefinition.
- wiring: JOB_MANAGER service key, base_manager job_manager property +
  _TOOL_SERVICE_MAP + lazy init branch; max_concurrent_jobs setting.
- /jobs command (running+queued; all/<id>/cancel/clean).
- tests/tools/test_jobs.py (15 tests): backends, cap+queue, cancel, persistence,
  vanished-process reconcile, clean, tools via service registry.

Deferred: harness Jobs UI monitor (M2), REST/cloud backends + domain tools (M3),
push/resume auto-ingest (phase 2). Offline suite: 1552 passed.
…B_TOOLS=[job_status]

Addresses tool-sprawl: most agents now add just two job tools — their typed
long-running tool + job_status. job_status is enriched to also return the
result once the job is terminal, making job_result/job_logs redundant for the
LLM. JOB_TOOLS slimmed to [job_status]; the full set moves to opt-in
JOB_MANAGEMENT_TOOLS (which also powers /jobs). All five tools stay registered
and importable; listing/cancelling is normally a human action via /jobs.
feat(jobs): long-running job substrate (Tier A milestone 1)
Add a background JobMonitor that runs for the lifetime of the CLI session,
independent of the agent loop. Each tick it reconciles the JobManager (so
detached jobs advance state with no LLM turn) and renders a live jobs
segment into the status bar (`jobs: 2 running, 1 queued`), with a transient
✓/✗/⊘ note when a job finishes.

The status bar is the only background-safe UI surface: thinking_prompt boxes
are turn-oriented and the add_* methods print directly via
print_formatted_text (which would corrupt the live prompt from a background
coroutine), while set_status only updates text + invalidates the app.
WorkflowController stays the single composer of the status bar and reads the
segment the monitor publishes via `jobs_status_segment`; the monitor refreshes
the bar only when the segment changes.

- src/agentic_cli/cli/job_monitor.py: JobMonitor (poll loop, segment builder)
- cli/workflow_controller.py: jobs_status_segment field + splice into bar
- cli/app.py: start/stop the monitor over the session lifetime
- examples/jobs_demo.py: interactive agent with run_shell_job to exercise it
- tests/cli/test_job_monitor.py: segment logic, poll_once, and a live
  background-loop test against real subprocess sleep jobs
…shadow warnings

Point the demo's env_file at the research demo's .env so keys live in one
place (fixes 'No API keys configured'), and switch app_name/workspace_dir/
max_concurrent_jobs/permissions_enabled to the __init__ setdefault pattern
research_demo uses, which removes the 'shadows an attribute' UserWarnings.
run_shell_job runs commands via the subprocess backend (sh -c) and does NOT
go through the hardened shell tool (tools/shell/) — no command classifier, no
blocked-pattern checks — so shipping it as a built-in handed the LLM arbitrary
shell behind only a default-ASK permission. The regular shell tool is disabled
for exactly this reason.

The framework now ships only the generic job substrate (JobManager, backends,
observe-only job_* tools, JobMonitor, /jobs). The typed long-running *starter*
tool is application-provided: a reference run_shell_job now lives in
examples/jobs_demo.py with an explicit security note. We'll revisit a built-in
shell-job tool when the regular shell tool is re-enabled with security levels,
or when shell runs inside an OS sandbox.

- tools/jobs/tools.py: drop run_shell_job; docstring explains starters are app-provided
- tools/jobs/__init__.py, tools/__init__.py: remove run_shell_job export
- workflow/base_manager.py: keep run_shell_job name in _TOOL_SERVICE_MAP by
  convention (app-provided), with a clarifying comment
- cli/builtin_commands.py: generic /jobs "not available" hint
- examples/jobs_demo.py: define run_shell_job locally (register_tool + JobManager)
- tests/tools/test_jobs.py: long_running flag tested via a probe tool; service-
  registry test submits via JobManager directly instead of importing run_shell_job
feat(jobs): harness Jobs UI monitor (Tier A milestone 2)
Groundwork for phase-2 push/resume auto-ingest. No behavior change yet — this
only records who/what to resume; nothing reads it to actually resume an agent.

- JobRecord gains persisted resume fields: session_id, user_id,
  resume_on_complete, call_id (ADK function_call_id / LangGraph tool_call_id),
  call_name, resumed. to_dict/from_dict cover them automatically.
- JobManager.submit() accepts resume_on_complete + call_id/call_name/session_id/
  user_id. When resume_on_complete is set and session/user are omitted, it
  best-effort auto-fills them from the active workflow turn via the WORKFLOW
  service (missing context is non-fatal: the job runs, just isn't resumable —
  logged). call_name defaults to the tool name.
- JobManager.awaiting_resume() (terminal + flagged + not resumed, reconciles
  first, oldest-finished-first) and mark_resumed() (durable, double-resume guard)
  — the query/commit API the coordinator will use.
- BaseWorkflowManager tracks the active turn: _workflow_context(session_id,
  user_id) sets active_session_id/active_user_id and clears them on exit (even
  on error); ADK + LangGraph process() pass the current session/user through.

Tests: resume-metadata storage + persistence + autofill + missing-context +
awaiting_resume/mark_resumed (tests/tools/test_jobs.py); active-turn set/clear
incl. on-exception (tests/workflow/test_active_turn_context.py).
feat(jobs): resume association layer (push/resume milestone 1)
Wire the ADK side of phase-2 push/resume. Still no auto-trigger (that's the
coordinator, milestone 3) — this provides the execution primitive.

- _wrap_long_running(): tools flagged long_running are wrapped as ADK
  LongRunningFunctionTool at agent-build time (both leaf + coordinator agents).
  The model is told not to re-call while pending; permission gating is
  unaffected (ADK gates by name via PermissionPlugin, not by wrapping).
- resume_with_job_result(record, result=None): delivers a finished job's result
  to the pending tool call as a FunctionResponse(id=call_id, name=call_name,
  response=<summary>) and re-invokes the runner, streaming the follow-up turn.
  Requires the originating session (which holds the pending call) — early-returns
  with a warning if the ids or session are missing. Result defaults to fetching
  from the JobManager; payload is a summary + pointer (job_result/job_logs), not
  raw data, to keep context lean.
- Factored the runner event/plugin-draining loop out of process() into a shared
  _run_and_stream() (+ _build_run_config()) used by both process() and resume.
- examples/jobs_demo.py: run_shell_job captures tool_context.function_call_id and
  submits with resume_on_complete=True so it's ready for the coordinator.

Tests (tests/workflow/test_adk_job_resume.py): wrapping only flags long_running
tools (idempotent); resume builds the right FunctionResponse + streams; fetches
result from JobManager when omitted; missing call_id / missing session yield
nothing. Live end-to-end (real model reacting to the FunctionResponse, and the
pending-response-shape question) is milestone 4.
Single-process drive of the real resume mechanic against a live Gemini model:
turn 1 calls a long-running tool (job starts pending, call_id captured from
tool_context), then resume_with_job_result hands the finished result back via a
FunctionResponse and the model reacts. Parametrized on the tool's initial-return
shape — pending dict vs None — to answer the open question.

Result: BOTH shapes pass. ADK accepts a second FunctionResponse for the pending
long-running call and the model resumes. So the demo keeps the informative
{"status":"pending", job_id} shape. This de-risks milestone 3 (the coordinator)
before building on resume_with_job_result.

@pytest.mark.llm, ADK-only (needs GOOGLE_API_KEY); skipped by default.
feat(jobs): ADK idiomatic push/resume execution (milestone 2)
Closes the push/resume loop end to end (ADK). When a finished long-running job
opted in (resume_on_complete), the agent is auto-resumed with its result — no
polling.

- BaseCLIApp.resume_finished_jobs(): drains JobManager.awaiting_resume() into
  serialized resume turns (one at a time via a new _turn_lock; marks resumed
  before running so a crash can't double-fire). Called at turn boundaries when
  job_auto_resume is on, and by /resume on demand (ungated).
- MessageProcessor.process_resume(): streams resume_with_job_result through the
  exact same rendering as a user turn. Factored the shared turn machinery
  (events box, HITL callback, Ctrl+C cancel, retry, token accounting) out of
  process() into _run_turn(source_factory).
- job_auto_resume setting (default off); /resume command (ResumeCommand);
  JobMonitor shows "↻N to resume" when enabled.
- examples/jobs_demo.py sets job_auto_resume=True to demo the feature.

Tests: coordinator drain/order/guards with fakes (tests/cli/test_resume_
coordinator.py); status-bar resume cue (tests/cli/test_job_monitor.py); and a
live full-loop test driving the real coordinator -> process_resume ->
resume_with_job_result -> model (tests/integration/test_live_job_resume.py::
test_full_resume_loop_via_coordinator). Offline 1582 passed; 3 live tests pass.

The coordinator + association are backend-agnostic; only resume_with_job_result
is ADK-specific so far. LangGraph resume is milestone 5.
feat(jobs): push/resume coordinator + /resume (milestone 3)
A finished resume-flagged job whose originating conversation is gone (e.g. after
a CLI restart — ADK's default session is in-memory) no longer fires a dead
"resuming" turn with no output. Instead the harness posts a one-line notice and
the result stays reachable by id.

- BaseWorkflowManager.can_resume(record): default False (no resume support).
  GoogleADKWorkflowManager overrides it to require the resume ids AND a live
  session that still holds the pending call.
- MessageProcessor.process_resume gates on hasattr(resume_with_job_result) AND
  await workflow.can_resume(record): resumable → resume turn as before; not
  resumable → "✗/✓ job 'x' finished while its conversation was unavailable —
  fetch with /jobs <id>" + return (coordinator already marks it resumed, so it
  notifies once; the result is reachable via /jobs).

Restart UX needs no spontaneous startup turn (that would repaint the live
prompt): awaiting_resume is persisted, the monitor's "↻N to resume" cue shows it
after a restart, and the next turn boundary / explicit /resume drains it
(resume when the conversation survived, notice when it didn't).

Tests: ADK can_resume true/false on session presence + missing ids
(tests/workflow/test_adk_job_resume.py); process_resume resume-vs-notice
branching incl. a backend with no resume support
(tests/cli/test_process_resume.py). Offline 1588 passed; full-loop live test
still passes through the can_resume gate.
feat(jobs): graceful restart handling for push/resume (milestone 4 / A)
…rvice (M1)

First step of resumable sessions (design: docs/plans/2026-06-18-resumable-
sessions-design.md). Non-breaking: adds native durability; the legacy JSON
save/load still functions on top (removed in a later step of this branch).

- session_store setting (memory | sqlite | postgres), default sqlite. One
  resolver BaseSettings.session_db_url() returns the async SQLAlchemy URL shared
  by both backends (sqlite+aiosqlite:///{workspace}/sessions/sessions.db default;
  postgresql+asyncpg:// for postgres).
- ADK: _make_session_service() builds DatabaseSessionService(db_url) (durable,
  full event fidelity incl. function-call ids) vs InMemory for memory; creates
  the sqlite dir. Wired into _do_initialize.
- deps: aiosqlite + greenlet (SQLAlchemy async; greenlet was missing — verified
  via spike that DatabaseSessionService needs it).

Verified: session_db_url + service selection unit tests
(tests/workflow/test_session_store.py); full offline suite 1595 passed.

Still on this branch: session_id/--session resume semantics, route LangGraph
checkpointer off session_store, repoint /sessions to native stores, drop the
JSON SessionPersistence layer + _extract/_inject, restart-resume test.
Completes resumable sessions (design: docs/plans/2026-06-18-resumable-sessions-
design.md). Conversations persist continuously via each orchestrator's native
store, keyed by session id; the lossy save-on-exit JSON layer is removed.

- Factory routes the LangGraph checkpointer off session_store (persistent by
  default; thread_id == session_id).
- Native session API on the managers: session_exists / list_sessions /
  delete_session / recent_messages (ADK via DatabaseSessionService;
  LangGraph via the checkpointer). save_session is a no-op flush; load_session
  adopts the id and reports whether it's a real resume. Removed the abstract
  _extract/_inject hooks + both lossy implementations.
- app.py: a fresh durable session id per run; --session resumes natively (only
  an explicit id triggers a resume); removed save-on-exit (continuous
  persistence) and the JSON inject-on-load. on_session_end sources recent
  messages from the native store.
- /sessions reads/deletes from the native store (SessionsCommand).
- Removed SessionPersistence/SessionSnapshot + the on-exit/JSON tests.
- examples/research_demo: --session <id> flag.

Validated live (tests/integration/test_live_durable_sessions.py): a brand-new
manager over the same sqlite resumes the session and the model recalls prior
context across a simulated restart. Offline suite 1566 passed.
- Drop the langgraph_checkpointer setting (the factory now routes the
  checkpointer off session_store; the field was unused). Tests updated to
  session_store.
- Remove now-orphaned imports in adk/manager (json, google.adk.events.Event)
  left by dropping _extract/_inject.

Offline suite 1566 passed.
feat(sessions): native durable sessions by default (drop JSON layer)
The persistent checkpointers (AsyncSqliteSaver/AsyncPostgresSaver) don't
implement the sync state path, so `_get_state_values` (sync `get_state`) silently
returned None with the default sqlite store — making `session_exists` always
False (LangGraph resume/`/sessions` broken) and killing the LangGraph task box.

- `_get_state_values` is now async and uses `aget_state`; `session_exists` and
  `recent_messages` await it.
- `_build_task_progress` is async too (reuses `_get_state_values`); its two call
  sites in `process()` await it.

Adds a live LangGraph durability test (Claude → LangGraph backend) alongside the
ADK one: a fresh manager over the same checkpointer recalls a codeword across a
restart. Both pass; offline suite 1566 passed.
fix(sessions): LangGraph durable sessions need async aget_state
The LangGraph LLM path sent thinking_level to all gemini models, but gemini-2.5
rejects it ("Thinking level is not supported for this model", 400) — so gemini
was unusable on the LangGraph backend. Mirrors the ADK fix
(GoogleADKWorkflowManager._get_planner): gemini-3 → discrete thinking_level;
gemini-2.5 → numeric thinking_budget ({low:4096, medium:12288, high:24576}).
ChatGoogleGenerativeAI accepts both; get_llm passes whichever the config has.

- graph_builder.get_thinking_config: split gemini-3 (thinking_level) vs
  gemini-2.5 (thinking_budget); get_llm passes the right kwarg.
- test updated to assert thinking_budget for gemini-2.5 (no thinking_level).
- the live LangGraph durability test now runs gemini-on-LangGraph (only needs
  GOOGLE_API_KEY) and exercises this fix end to end.

Verified live: a gemini-2.5-flash turn on LangGraph returns cleanly (no 400);
both durable-session tests (ADK + LangGraph, gemini) pass. Offline 1566 passed.
fix(langgraph): gemini-2.5 needs thinking_budget, not thinking_level
Introduce a backend-neutral ModelSettings (temperature, top_p, top_k,
max_tokens, stop_sequences, thinking, extra) + ThinkingSettings, and wire
it into the ADK manager so generation params and thinking effort can be set
per agent rather than only globally.

- workflow/model_settings.py: ModelSettings / ThinkingSettings models.
- AgentConfig.model_settings: optional, backward-compatible.
- adk/manager: _get_planner / _get_generate_content_config are now
  per-agent; thinking resolves per-agent with fallback to the global
  thinking_effort, respecting per-agent model override for the Gemini-3
  thinking_level vs 2.5 thinking_budget split; adds explicit budget mode.
  extra is filtered to valid GenerateContentConfig fields.
- Export ModelSettings/ThinkingSettings from agentic_cli and workflow.

Phase 1 of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
AgentConfig.tools entries may now be strings — a registered tool name
(e.g. "kb_search") or a dotted import path (e.g. "my_pkg.tools.my_tool") —
in addition to callables.

- tools/tool_resolver.py: resolve_tool/resolve_tools. Bare name -> framework
  ToolRegistry (lazily importing agentic_cli.tools to self-register built-ins);
  dotted path -> import; callables/objects pass through. Helpful error with
  close-match hint on unknown names.
- AgentConfig.tools widened to list[Callable | str].
- base_manager resolves config tool refs in __init__, before service
  detection and tool assembly (both key on tool.__name__).

Phase 2 of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
Define agents declaratively in the framework's own YAML format and load them
into AgentConfig objects (distinct from native ADK root_agent.yaml).

- workflow/agent_loader.py: load_agents_from_yaml() + AgentSpec/AgentsFile
  schemas (extra="forbid"). Supports prompt/instruction aliases,
  instruction_file (relative to the YAML), nested model_settings, and a bare
  top-level agent list. Tool refs stay strings (resolved at manager build).
  create_workflow_manager_from_yaml() convenience wrapper.
- Export loader funcs from agentic_cli.workflow.
- Declare PyYAML as a direct dependency.

Phase 3 of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
Agents can declare MCP servers (stdio/sse/http); their tools are exposed to
the ADK agent via an McpToolset.

- workflow/mcp.py: MCPServerConfig + build_connection_params + to_adk_toolset
  (uses the non-deprecated McpToolset; ADK connects lazily, so sync).
- AgentConfig.mcp_servers + agent_loader (YAML) support.
- adk/manager: _assemble_agent_tools appends MCP toolsets per agent.
- permission_plugin: MCP toolset tools aren't in the registry, so gate them
  through the engine under a synthetic 'mcp' capability (no rule -> ASK)
  instead of hard-denying; non-MCP undeclared tools still denied.
- Export MCPServerConfig from agentic_cli.workflow.

Phase 4 of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
Agents can declare skills (Agent Skills / SKILL.md folders); they're exposed
via ADK's native SkillToolset with L1 progressive disclosure. Script execution
is disabled by default — run_skill_script is removed from the toolset unless
settings.skill_scripts_enabled is True (executor wiring is a follow-up).

- tools/skills/: SkillStore (resolve paths/names via ADK's loader),
  make_skill_toolset (drops run_skill_script when scripts disabled), and
  permission registration for the ADK skill tool names (reads EXEMPT,
  run_skill_script permissioned) so the PermissionPlugin allows them.
- AgentConfig.skills + agent_loader (YAML) support.
- adk/manager: _build_skill_toolset resolves + attaches per agent.
- settings: skills_dirs, skill_scripts_enabled.

Phase 5 of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
…slate)

Support pointing the framework at an existing ADK root_agent.yaml.

- workflow/adk_config_bridge.py:
  - load_adk_agent_native(): build the agent tree via ADK from_config (full
    fidelity; ADK backend only).
  - translate_adk_yaml(): best-effort conversion to framework AgentConfig list
    (instruction/model/tools/sub_agents + generate_content_config->model_settings;
    ADK-only fields like planner/callbacks/code_executor dropped with a warning).
- adk/manager: accept adk_config_path; build root via native loader when set.
- factory: adk_config_path + adk_config_mode ("native" default, or "translate"
  which routes through the normal manager path so framework features apply).
- Export bridge funcs from agentic_cli.workflow.

Phase 6 (final) of the unified agent-config work (ADK-only scope).

Claude-Session: https://claude.ai/code/session_01Lj9Z4qwfXyDtahBoNAeqgb
shoom1 added 29 commits July 12, 2026 13:36
… post-fetch recheck (P0-5)

Also wire PinnedTransport into get_or_create_fetcher (webfetch_tool.py) so the
runtime factory passes the now-required transport kwarg to ContentFetcher.

Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
… test hygiene (P0-5 review)

Whole-branch review: PinnedTransport.aclose() no longer tears down the shared
inner pool (fixes concurrent web_fetch fragility); + no-DNS guard on validate()
and a factory-test cache teardown.

Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
fix(security): P0-5 SSRF-safe webfetch — resolve-validate-pin transport, robots + streaming
… allowlist

P0-1 (#103) made the loader drop non-allowlisted keys from the project
settings.json, but save() still dumped every field there: each /settings
save recreated the untrusted_project_setting_ignored warning storm, and a
change to a user-scoped key (e.g. stateful_executor_backend) was silently
reverted on the next start.

save() now mirrors the load-side trust model:
- project file gets ONLY allowlisted keys, fully rewritten (heals stale
  pre-P0-1 kitchen-sink files)
- user ~/.{app}/settings.json gets user-scoped keys that differ from the
  settings class default, via read-merge-write that preserves unmanaged
  keys (hand-stored secrets, domain keys); reverting a key to its default
  removes it so the revert sticks and code-default changes keep applying
- explicit save(path=...) keeps the legacy single-file full dump

PROJECT_SETTABLE_KEYS moves to settings_persistence (config.py imports it;
the reverse would be circular) so writer and reader share one allowlist.
save() returns SettingsSaveResult; /settings reports both paths.

Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
fix(settings): trust-split save so /settings changes survive the P0-1 allowlist
Policy follow-up to #105: settings that persist at user level must not be
editable via /settings — a dialog edit to a non-allowlisted key would land
in ~/.{app}/settings.json and silently apply across all projects of the
app.

_build_ui_items() now excludes (with a per-key warning) any key whose
target field is not in PROJECT_SETTABLE_KEYS, regardless of what a domain
app returns from get_ui_setting_keys(). The synthetic "model" key passes
via the _UI_KEY_TARGET_FIELDS alias (set_model() writes default_model,
which is allowlisted). Dangling keys for removed fields (e.g.
airesearcher's log_activity) now warn instead of being silently skipped.

Programmatic update_setting()/save_settings() with user-scoped keys stays
legitimate — the split-save from #105 remains their writer; the dialog
just can't produce them.

Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
fix(settings): guard /settings dialog to project-scoped keys
…claim

Review finding 7 (countered): export_api_keys_to_env() set provider env
vars only when absent, so in a process with two settings instances the
first manager's export silently pinned credentials for every later one —
while config.py advertised SettingsContext for multi-tenant use.

The reviewer's fix (pass credentials to every provider client) fights
ADK 1.x: AnthropicLlm/Gemini construct SDK clients from env internally.
Instead:
- export now OVERWRITES env from the settings instance. This is
  precedence-consistent: the key fields bind only via their env alias
  (validation_alias, no populate_by_name — constructor kwargs and JSON
  files never set them), so divergence only means the process env changed
  after this instance loaded, and the configured value must win. Unset
  keys still leave the environment untouched.
- config.py no longer claims multi-tenant isolation for SettingsContext;
  it documents that credentials are process-global env vars.

Claude-Session: https://claude.ai/code/session_01SwkKXQpy3JLEqrPkQA8QRv
Permission gating and tool assembly resolved tools by ``__name__``, so an
unregistered callable named after a genuine tool inherited its capabilities —
including EXEMPT — and was handed its services and its long-running contract.
Identity is now a per-``ToolRegistry`` ``id(obj) -> (weakref, definition)``
map confirmed with ``is``: no name path, no equality, and a recycled address
inherits nothing. Everything the framework issues is bound at its construction
site (registered callables, factory service-bound variants, renamed wrappers,
the native ADK skill tools). Anything unbound is denied, and left exactly as
the application supplied it during assembly. Keeping the map on the instance
is also what lets a short-lived registry and its closures be collected.

A name may now mean only one thing: ``register()`` raises on any duplicate.
Sharing is declared, never inferred — ``declare_tool(name, ...)`` states a
contract with no backend-neutral implementation, and each backend registers
its own with ``register_tool(..., variant_of=name)``. The ADK and LangGraph
save_plan/get_plan/save_tasks/get_tasks are declared once in
``tools/_core/state_tools.py``; previously they contested the name and import
order decided the winner. ``canonical_for()`` keeps assembly from ever
yielding a declaration's absent ``func``, and ``replace=True`` retires the old
definition's identities so they resolve to nothing.

``service_registry.KNOWN_SERVICE_KEYS`` lands here rather than with the tool
declarations that use it: ``registry._validate_requires`` imports it at module
scope, so the mechanism and its vocabulary cannot be separated.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
``BaseWorkflowManager._TOOL_SERVICE_MAP`` was a central name→service table, so
a downstream tool that needed a framework service could only get one by
editing the framework — and it matched on ``__name__``, which meant an
application function named ``kb_search`` had a knowledge base built for it
even though the permission engine would deny the call.

Tools now declare their own needs with ``@register_tool(..., requires=...)``,
validated at registration against ``service_registry.KNOWN_SERVICE_KEYS`` so
only genuinely constructible services can be declared (``user_kb_manager`` is
created together with ``kb_manager``, and the error says so). Detection reads
that metadata off the registry by identity, so ``register(func, name=...)``'s
original callable still declares its services while an unregistered lookalike
declares nothing. There is still no mechanism for registering new service
*types*.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
Multiple roots were silently accepted and all but one tree was unreachable —
the runner starts from a single root, so those agents could never run.
Duplicate names, dangling ``sub_agents`` references, self-references,
delegation cycles and a child claimed by two parents were likewise only
discovered as a half-built hierarchy, after model discovery and service
creation had already paid for themselves.

``validate_agent_graph()`` now returns a validated ``AgentGraph`` (config map,
dependency-ordered build order, root) and raises ``AgentGraphError`` naming
the offending agents — before any allocation or network call, since a bad
graph is a static configuration error. Agents are built in dependency order,
so declaration order no longer changes the hierarchy.

Prompt factories are resolved under the manager's settings rather than the
global singleton: a factory may take no arguments (including all-defaulted
ones) or exactly one settings argument. Other signatures, ``async def``
factories and non-string results are rejected by name instead of producing a
coroutine object as an agent's instruction.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
…covery

A manager's own model — ``Manager(model=...)``, ``reinitialize(model=...)``,
or a ``settings.get_model()`` cached before discovery ran — was never in
settings and so never validated: an unusable id reached the provider at first
use, and a deprecated alias kept being sent even though ``check_model()``
already knew its replacement (only ``set_model()`` wrote one back). Every
effective model now goes through one all-or-nothing pass via the internal
``_validate_settings_with_models()``, and the resolved ids are applied only
after all of them validate. ``validate_settings()`` itself is unchanged and
still returns None.

Validation also runs *after* discovery: the static fallback list would
otherwise reject a model that exists but postdates it. Discovery authority is
tracked per provider, so a Google outage no longer makes the Claude listing
non-authoritative — nor an Anthropic outage make Anthropic's hardcoded
fallbacks authoritative. An unknown model is never silently swapped for a
near neighbour; it is an error naming what was asked for.

Also here, since they are the same listing path: the provider SDK clients are
closed after a listing rather than left to GC with their connection pools
open, and the blocking Anthropic listing runs on a worker thread so startup
does not block the event loop.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
``BaseSettings(google_api_key="…")`` silently bound nothing. Each credential
field declared only its environment-variable ``validation_alias``, so with
``populate_by_name`` off the field name was not an accepted input at all and
``extra="ignore"`` swallowed the kwarg — the setting kept its default and the
caller got an unauthenticated client with no error. Every credential now
accepts both names via ``AliasChoices``, with the env name first so a real
environment variable still wins within a source.

Because the field name is now accepted, a misspelled credential kwarg would
be dropped just as quietly, so constructor kwargs that *look* like credentials
but match no field are rejected by name. Credential values are also kept out
of ``repr()``.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
A conversation's identity is the ``(app_name, user_id, session_id)`` triple,
but the session hooks took only a session id and implicitly used the manager's
default user — so listing, deleting, or reading the history of another user's
session silently answered about the wrong conversation, or came back empty.
``session_exists``/``list_sessions``/``delete_session``/``recent_messages``/
``load_session``/``save_session`` now take an optional ``user_id``, defaulting
to ``settings.default_user`` only when the caller omits it, and
``on_session_end(session=SessionRef(...))`` reads the conversation it is
given. Base-class helpers still call the backend hooks *without* ``user_id``
when it is the default, so a downstream override that never added the
parameter keeps working.

The in-flight ``(user, session)`` is a ContextVar set with a token by
``_workflow_context()``, so concurrent turns on one manager stay isolated and
nesting restores the outer turn.

A backend with no durable store now leaves ``supports_sessions`` False and the
base hooks raise ``NotImplementedError`` rather than answering ``False``/``[]``
— an empty list read as "you have no saved sessions" when the truth was "this
backend keeps none", which ``/sessions`` now says outright.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
A manager ran turns concurrently against one backend, and lifecycle mutation
could land in the middle of one. ``process()``/``resume_with_job_result()``
now enter through ``_turn_admission()`` (turn lock) while
``initialize_services``/``reinitialize``/``cleanup`` hold the lifecycle lock
*and* the turn lock. Lock order is lifecycle → turn, so a turn initializes
before taking the turn lock — which is what keeps the two from deadlocking,
and is also why a queued cleanup could run in between and leave the turn
holding admission to a released backend (a ``None`` runner, surfacing as an
AttributeError deep inside ADK). Admission therefore re-checks
``_backend_ready()`` while holding the turn lock and reinitializes once, or
fails cleanly.

Initialization is transactional. Services are built on a worker thread into a
*local* dict and published only while the attempt still owns init: writing
straight into the manager meant a cancelled attempt was followed, moments
later, by that uncancellable thread publishing into a manager that had already
been cleaned up. A cancelled attempt now releases whatever the thread went on
to build, and a constructor that raises releases its predecessors — nothing
was published, so nobody else could ever have closed them.

``cleanup()`` is idempotent and awaits an async ``close()`` on owned
resources. A failed in-place reinitialization *keeps* the manager: it rolled
itself back to uninitialized, but still owns the session service its own
``reinitialize(preserve_sessions=True)`` restored, and releasing it threw away
the conversation for a failure the user could correct and retry.

The HITL input callback becomes a per-manager ContextVar for the same reason:
it is one manager with possibly two consumers, and a plain attribute let the
second one capture a running turn's prompt and let either one unregister the
other's callback.

``WorkflowController`` gains a derived ``WorkflowState`` that can never drift,
serializes every lifecycle transition on its own lock, and never publishes
after ``close()``. Construction in the init executor is shielded and tracked
by a single-shot claim: cancelling the await used to cancel the asyncio future,
after which asyncio silently discarded the manager the (uncancellable) thread
returned. Shutdown runs in a task the controller owns and callers join under a
shield, so a cancelled caller cannot abandon a teardown half-done — including
the cleanup of an abandoned construction, which a later ``close()`` joins.
``_init_error`` is cleared on every success, so state, ``ensure_initialized()``
and the status bar can no longer disagree about a recovered controller.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
ADK appends the turn's input — the user message, or a resumed
``FunctionResponse`` — to the session while setting up the invocation, before
the first event. There is therefore no point at which re-running a turn is
side-effect free, and the harness's "Retry in Ns?" dialog was replaying turns
whose input had already been persisted. The event source is now invoked
exactly once and a surfaced rate limit fails the turn explaining that;
transient retries belong to the provider client (ADK ``HttpRetryOptions``,
Anthropic ``retry_max_attempts``).

``MessageProcessor.process()`` returns a typed ``TurnResult``
(COMPLETED/CANCELLED/FAILED/UNAVAILABLE) instead of a bare bool, so a caller
can tell "the user cancelled" from "the turn failed". ``EventType.ERROR`` had
no handler at all and was silently swallowed; it is now rendered as it
arrives, with ``recoverable=True`` a warning that leaves the outcome to the
stream and anything else failing the turn.

Cancelling the caller used to leave the child consumer task driving the
workflow while the turn was torn down around it; the consumer is now cancelled
and awaited *before* the input callback is cleared, so no tool is left asking
a question nobody owns. The HITL dialog's ``finally`` reopened a replacement
events box even while unwinding, stranding a thinking box on screen — the box
now reopens only on success and is finished exactly once.

Session-fact extraction moves inside the ``background_init`` context: it needs
the live session store and an LLM call, and leaving the context closes the
manager first.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
The jobs directory is user-scoped, so two CLIs routinely hold the same
records — and every mutator acted on its own in-memory snapshot.

Delivery has an explicit lifecycle: ``JobRecord.resumed`` (a bool set *before*
the turn ran) becomes ``resume_state`` (pending → resuming → delivered|failed)
with ``resume_error`` and ``resume_owner``, claimed with ``begin_resume()``
and closed with ``complete_resume()`` on success, failure *and* cancellation.
Both transitions happen under a cross-process ``flock`` against the record on
disk, so two CLIs can no longer deliver one result into two conversations. A
record found ``resuming`` at startup is recovered as failed rather than
replayed — the interrupted turn may already have run tools.

Execution is claimed the same way: ``exec_owner`` (``<host>:<pid>:<manager>``)
is written *before* the backend is started, so a queued job cannot be launched
twice, and an interrupted launch is failed rather than replayed. Every other
metadata write reloads the durable record first — a plain state write carried
this manager's stale resume fields and erased another process's live claim —
and terminal transitions are monotonic, so a stale snapshot cannot rewrite a
recorded success as CANCELLED. Reading distinguishes *deleted* from
*unreadable*: a record another manager cleaned away is forgotten rather than
resurrected, while an unparseable one is left untouched and fails closed.
Startup recovery honours the same distinction rather than writing a verdict
decided on state it failed to read.

A foreign job is polled (backends publish outcomes durably) but its
``UNKNOWN`` — "I hold no handle for this" — is ignored, so an observer sees a
job finish without marking a healthy one terminal. Whether a foreign job can
be *cancelled* is now a declared backend capability rather than inferred from
restart-safety, which answers a different question.

``InProcessBackend.close()`` no longer cancels submitted work. A queued job is
already durably RUNNING under a live owner, and a cancelled future writes no
exit-code sentinel — so no manager could ever resolve it, and it stayed
RUNNING forever.

If the cross-process lock cannot be taken, claims, launches, recovery,
reconcile, clean and cancel all fail closed rather than writing unsynchronized.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
…upplied

``skill_scripts_enabled`` advertised ADK's ``run_skill_script`` to the model
while no supported manager path wires a code executor, so every call it
provoked answered ``NO_CODE_EXECUTOR``. A switch that cannot make the thing
work is worse than no switch: the setting is removed and the tool is exposed
exactly when ``make_skill_toolset`` is given an executor — the thing that
actually makes it work. In practice scripts stay off; the parameter is there
for a caller that owns one.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
CHANGELOG/README/CLAUDE.md for the preceding ten commits, plus the top-level
exports the new contracts are addressed by — ``SessionRef``, ``TurnResult``,
``TurnStatus``, ``WorkflowState``, ``AgentGraphError`` — and a test that pins
the import surface so a package reshuffle cannot quietly drop one.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
fix(settings): API key export overwrites env; drop multi-tenant docs claim
refactor: framework review 2026-08 — tool identity, lifecycle, sessions, jobs
Two demo commands broke while the workflow was still coming up. ``app.workflow``
raises until the controller reports READY, so ``/memory`` and ``/kb-backfill``
surfaced the generic "Error executing command: Workflow not initialized yet"
during background init — which reads like a bug rather than "not yet". Both now
warn and return, using the same readiness shape the built-in commands use.
``ResearchDemoSettings``' documented precedence was also wrong: it omitted
constructor arguments, named the wrong project path, and did not say that the
project file is untrusted and allowlist-filtered.

The gap this closes on the test side is application *composition*. The workflow,
the renderer and the individual commands all had component tests; nothing
exercised a real ``ResearchDemoApp`` reacting to real input, and nothing
exercised the thing a user actually runs.

- Headless composition tests drive the real app through
  ``BaseCLIApp.process_input`` and the real ``MessageProcessor``, substituting
  only the nondeterministic workflow (scripted ``WorkflowEvent`` streams) and
  the UI (``RecordingSession``). They cover readiness warnings, message routing
  and session-id propagation, unknown commands, and recovery after a failed
  turn. Reverting the command fix turns two of them red with the exact reported
  error.
- A pty smoke drives the real console process: startup → prompt → /help →
  /exit, asserting no traceback and exit 0, with no API key, network, Docker or
  LLM. It also pins that the run is side-effect free and that the child resolves
  its modules from the checkout.
- A built-wheel acceptance builds the wheel, installs it into a throwaway
  virtualenv and runs the same session through the installed ``research-demo``
  console script, asserting the child resolves inside the venv and that the
  package data (benchmarks.csv, the report-writer SKILL.md, report_template.tex)
  shipped. It is opt-in (``wheel`` marker + ``AGENTIC_WHEEL_ACCEPTANCE=1``) and
  runs as its own CI job, so the offline suite stays fast and network-free.

``tests/demo_isolation.py`` exists because ``ResearchDemoSettings`` reads three
developer-owned sources that are *not* isolated alike: the two JSON files follow
``cwd``/``HOME`` at call time, but ``model_config["env_file"]`` is frozen at
class definition — i.e. at collection, before any fixture runs. Redirecting HOME
never moved it, so headless tests were reading the developer's real
``~/.research_demo/.env``. An explicit ``_env_file`` is the only thing that
moves it, and the effective path is asserted rather than assumed.

pexpect is declared in the dev extra rather than relied on transitively.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
Two defects made ADK's built-in agent hand-off unusable, so any demo or app
with ``sub_agents`` could not delegate at all.

**The routing tool was denied as unregistered.** ``PermissionPlugin`` unwraps
``.func`` only for exact ADK function-tool types, because a subclass may
override ``run_async`` and run something other than the callable it advertises.
``TransferToAgentTool`` is a ``FunctionTool`` *subclass* that ADK auto-injects,
so it resolved to no registry identity and was refused — even with permissions
disabled, since the refusal happens before the engine is consulted. Its exact
class now joins the trusted types, on the same terms and for the same reason
they are trusted: ADK constructs it as ``super().__init__(func=transfer_to_agent)``
and overrides only ``_get_declaration()`` (to add the agent-name enum), never
``run_async``, so what it invokes is still exactly ``self.func``. Listing the
exact class keeps every other subclass out; no name-based authority was
reintroduced and no alias was added.

**The tool told the model to call something that does not exist.** ADK builds
the declaration from ``transfer_to_agent``'s docstring, which through 1.37.0 —
the newest release inside our ``<2`` pin, checked against the published wheel —
advises callers to "use TransferToAgentTool instead of this function directly".
That paragraph is written for Python callers but ships to the model as the
tool's description, and Gemini 3.1 followed it, emitting ``TransferToAgentTool``
for ADK to reject with ``Tool 'TransferToAgentTool' not found``.

A narrowly scoped before-model plugin rewrites that description on the prepared
request. It acts only when the tool object is exactly ``TransferToAgentTool``
(an application tool sharing the name is untouched) and only while the
misleading sentence is present, so it is idempotent and becomes a no-op the day
the installed ADK ships a corrected docstring — upstream fixed it in 2.x. Only
``description`` is written: the declaration name, parameter schema, required
fields and the agent-name enum are preserved, and the upstream function's
``__doc__`` is never mutated.

Regression coverage asserts both halves, including that widening the trusted
type list opened no hole: an arbitrary ``FunctionTool`` subclass, a
``TransferToAgentTool`` subclass, a forged object named ``transfer_to_agent``,
one named after the class, and one carrying a copied ``.func`` all stay denied.
The declaration tests drive a synthetic misleading declaration rather than the
installed one, so they do not require upstream to remain broken; a single
integration test accepts either state and asserts the outcome is safe.

The demo's coordinator prompt gains a matching policy: an explicit bounded
request is executed or delegated immediately, an open-ended goal (or an explicit
request for a plan) is planned and held for confirmation, and planning is stated
to be a workflow courtesy rather than the authorization boundary — tool
permissions remain responsible for that. The live scenarios are split into three
independent contracts (planning, bounded delegation, KB ingest/readback) over a
persistent conversation, so one stochastic policy choice can no longer fail all
three.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
fix: demo startup hardening, release acceptance coverage, and safe ADK multi-agent delegation
The prompt's persistent option read "Allow always (save to project)". That
described the scope correctly but implied the grant is written into the
repository — it is not, and deliberately so: interactive grants persist to
``~/.{app_name}/project_grants.json``, keyed by the resolved project path, so a
repo cannot ship pre-approved allow-rules that a clone would silently honour.
The label now reads "Allow always for this project", which states the scope
without the misleading implication.

Only the displayed string and the ``ALLOW_ALWAYS_CHOICE`` constant change.
Authorization, grant scope, where grants are stored, rule matching and
fail-closed behaviour are untouched. ``parse_response()`` still accepts the
superseded wording (kept private), so an answer captured or queued under the old
label keeps meaning "always" instead of silently degrading to a denial; it is
never offered as a choice.

Also corrects two stale docstrings and a test comment that predate durable
sessions. ``can_resume()`` claimed the conversation is gone after a CLI restart
"since the default session is in-memory". Sessions are durable by default
(``session_store='sqlite'``) and normally survive a restart; the conversation is
unavailable when it was deleted, the record lacks its session/user/call ids, or
the run used the explicitly ephemeral ``session_store='memory'`` and the process
restarted. Documentation only — no runtime behaviour changes.

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
Cut the accumulated Unreleased section as 0.6.0 and bump the version in
pyproject.toml and __init__.py.

Three security PRs had landed on develop with no changelog entry at all, so
the notes advertised two P0 fixes when the release contains five. Added
concise entries for the webfetch SSRF pinning (P0-5), symlink-safe sandbox
transfer (P0-2), compile_document host containment (P0-3), glob/grep root
containment (P0-4), and the fail-closed permission engine (P1-7). Also merged
the duplicate "### Removed" block the section had grown.

Adds the final release notes for this cycle: ADK multi-agent delegation
(the native transfer tool is recognised by exact identity, and ADK 1.x's
misleading model-visible class-name instruction is corrected without weakening
fail-closed permissions), the research-demo readiness behaviour for /memory and
/kb-backfill, the built-wheel and installed-console acceptance coverage, and the
rewording of the persistent permission choice.

Also carries a public-documentation audit against the implementation on develop.
README and CLAUDE.md had drifted: ADK was described as Google-only (it runs
Claude natively via DirectAnthropicLlm) and as in-memory (sessions are durable
by default); the command table omitted /jobs and /resume and credited the demo
with a /save command that does not exist; "Allow always" grants were said to be
written into the project settings file rather than the user-side, path-keyed
project_grants.json; a Tool Reflection feature was documented whose module was
removed; the structure listing still named the deleted SessionPersistence; a
link pointed into the gitignored docs/ scratchpad; and configuration precedence
and its trust boundary were undocumented. The research-demo invocation, the
tool-registration contracts (requires=, declare_tool/variant_of) and every
quick-start snippet were checked against the current code.

Minor, not patch: the tool-identity, session and turn-result contracts are
breaking (see Changed/Removed).

Claude-Session: https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh
@shoom1
shoom1 merged commit cbf773b into main Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant