Conversation
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
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v0.6.0 —
develop→main. 158 commits (129 non-merge) sincev0.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
JobManagerover pluggable execution backends (subprocess,inprocess), restart-safe completion via an on-diskexit_codesentinel, a concurrency cap and queue, a/jobscommand, and a backgroundJobMonitorthat 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;/resumeon demand).DatabaseSessionService, LangGraph via a persistent checkpointer — keyed by session id.session_storeselectssqlite(default),postgres, ormemory.--session <id>resumes;/sessionslists and deletes. Persistence is per-event and full-fidelity, so it survives a crash mid-turn and keeps function-call ids intact.sandbox_execute, with a dedicated CI job that fails rather than skips when a runtime is missing.compile_documentfor LaTeX output.research-democonsole script.Security
Five P0 fixes plus the fail-closed permission hardening:
./.{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.copy_regular_file_no_follow), outputs are0700.O_NOFOLLOWguards only the final component, so the parent directory islstated too.compile_documenthost execution is contained. Private temp build dir, exactTEXMFallowlist, anchored source filename,RLIMIT_FSIZE/RLIMIT_CPU, bounded captured output, SIGKILL on timeout.glob/grepare 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.web_fetchcannot reach internal networks. APinnedTransportresolves every A/AAAA record up front, rejects if any is non-global (including NAT64 and 6to4-relay forms), then connects to the validated IP withsni_hostnameso TLS still binds to the hostname — closing the DNS-rebinding window. Redirects androbots.txtuse the same transport; bodies are capped while streaming.ADK multi-agent delegation repaired
Two defects made ADK's built-in agent hand-off unusable, so any app declaring
sub_agentscould not delegate at all.PermissionPluginunwraps.funconly for exact ADK function-tool types, and ADK's auto-injectedTransferToAgentToolis aFunctionToolsubclass — 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.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
ToolRegistry.register()raises on duplicate namesdeclare_tool+register_tool(..., variant_of=...); previously the last import silently wonToolRegistryare not framework-issueduser_idsession_exists/list_sessions/delete_session/recent_messages/load_session/save_sessiongain an optional keyword; overrides that never added it keep workingsupports_sessionsis False and base hooks raiseNotImplementedErrorinstead of answeringFalse/[]MessageProcessor.process()returnsTurnResultTurnResult.status;CANCELLEDandFAILEDwere previously indistinguishablesub_agents, cycles, shared children and more than one root raiseAgentGraphErrorsettingsarg;async defand non-string returns are rejectedskill_scripts_enabledremovedrun_skill_scriptis exposed exactly whenmake_skill_toolsetis given a code executorBaseWorkflowManager._TOOL_SERVICE_MAPremoved@register_tool(..., requires="kb_manager")JobRecord.resumedresume_state(pending → resuming → delivered|failed)ALLOW_ALWAYS_CHOICEvalue changed"Allow always for this project"; the former response wording is still parsed, so no behavioural changeBaseSettings(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 checkandgit diff --checkclean 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-democonsole script, confirming the child resolves inside the venv (not the checkout), that the packaged data files ship (benchmarks.csv, the report-writerSKILL.md,report_template.tex), and that the declared version reaches the wheel filename, the installed distribution metadata andagentic_cli.__version__. The version is derived frompyproject.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 successfulsearch_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
resume_with_job_resultis implemented for ADK; LangGraph resume is not wired.process(). The CLI catches it and renders a workflow error, but every non-CLI consumer inherits an undocumented contract. Whetherprocess()should propagate backend exceptions or convert them to fatalWorkflowEvent.ERRORevents is a deliberate post-0.6 decision.UNKNOWNpoll answer is filtered — a backend returning a wrong non-UNKNOWNstate 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
mainafter the merge).https://claude.ai/code/session_01BpktWb9vKN5exdT9MbLQYh