Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ jobs:
- name: Run offline test suite
run: conda run -n agenticcli python -m pytest -m 'not llm and not docker' -q

wheel-acceptance:
name: Built-wheel acceptance (console smoke)
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
steps:
- uses: actions/checkout@v4

- name: Set up conda env (agenticcli)
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
environment-file: environment.yml
activate-environment: agenticcli

# pexpect drives the console over a pty. Installed explicitly via the dev
# extra rather than relied on transitively.
- name: Install dev extra (pexpect)
run: conda run -n agenticcli pip install -e '.[dev]'

# Its own step rather than part of the offline suite: it builds a wheel
# and installs that wheel's dependencies into a throwaway virtualenv, so
# it is slow and needs the network. The opt-in env var is what the tests
# gate on, so the offline selector stays fast and offline.
#
# Targets the file rather than `-m wheel` alone: a bare marker selection
# still *collects* the whole suite, which would need the langgraph extra
# installed only to collect tests this job does not run.
- name: Run built-wheel acceptance
env:
AGENTIC_WHEEL_ACCEPTANCE: "1"
run: >
conda run -n agenticcli python -m pytest
tests/examples/test_research_demo_wheel.py -m wheel -v

docker-isolation:
name: Docker isolation tests
runs-on: ubuntu-latest
Expand Down
37 changes: 32 additions & 5 deletions examples/research_demo/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,35 @@ def report_writer_prompt() -> str:

Rule of thumb: concept pages > sidecars > chunks. Concept pages and sidecars are synthesis-first; chunks are evidence-first.

## Tool Names

Call only the tools that appear in your tool declarations, and call each one by
its exact declared name, exactly as written. If a task needs two tools, make two
separate calls. To hand work to another agent, call
`transfer_to_agent(agent_name="<one of your sub-agents>")`.

## When to plan, and when to just do it

Match the response to the size of the request:

- **Explicit, bounded request** — one clear operation with its parameters
already given ("search arXiv for two papers on X", "ingest this note",
"read it back"). **Do it now**: execute or delegate directly. Do not write a
plan and do not ask for confirmation; the user already told you exactly what
they want.
- **Open-ended or substantial multi-step research** — a goal rather than an
operation ("research X and write me a report", anything spanning several
tools or agents). **Plan first**: `save_plan(content)` with markdown
checkboxes, show the plan immediately, and wait for confirmation.
- **The user asks for a plan** — always plan, whatever the size.

Planning is a workflow courtesy, not a safety gate: what you are allowed to do
is enforced by tool permissions, not by whether you planned first. So never use
"I should plan" as a reason to refuse or defer a small, explicit request.

## Workflow Guidelines

When the user asks you to research something:
When the user asks you to research something open-ended:
1. **Check `kb_search_concepts(topic)`** — reuse any existing synthesis before deriving a new one.
2. Browse the knowledge base with `kb_list` to see what's already ingested.
3. Run `kb_search` only if you need evidence that isn't already summarized in a concept page.
Expand Down Expand Up @@ -255,13 +281,14 @@ def report_writer_prompt() -> str:
- ALWAYS show the plan after creating it
- ALWAYS show progress after completing tasks
- Share findings and learnings explicitly in your responses
- Ask for confirmation before starting lengthy work
- Ask for confirmation before starting *lengthy* work — not before a single
explicit operation the user has already spelled out
- Be thorough and detailed in your findings and reports
"""


AGENT_CONFIGS = [
# Leaf agent: arXiv specialist (must be listed before coordinator)
# Leaf agent: arXiv specialist
AgentConfig(
name="arxiv_specialist",
prompt=ARXIV_SPECIALIST_PROMPT,
Expand All @@ -279,15 +306,15 @@ def report_writer_prompt() -> str:
],
description="arXiv paper research specialist: search, analyze, save, and catalog academic papers",
),
# Leaf agent: data analyst (must be listed before coordinator)
# Leaf agent: data analyst
AgentConfig(
name="data_analyst",
prompt=DATA_ANALYST_PROMPT,
include_state_tools=False,
tools=[sandbox_execute, read_file, write_file, ask_clarification],
description="Stateful data-analysis specialist: loads datasets and runs multi-step pandas/plotting analysis in an isolated executor.",
),
# Leaf agent: report writer (must be listed before coordinator)
# Leaf agent: report writer
AgentConfig(
name="report_writer",
prompt=report_writer_prompt,
Expand Down
34 changes: 31 additions & 3 deletions examples/research_demo/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,26 @@
from agentic_cli.cli.commands import Command, CommandCategory

if TYPE_CHECKING:
from agentic_cli.workflow.base_manager import BaseWorkflowManager

from examples.research_demo.app import ResearchDemoApp


def _ready_workflow(app: "ResearchDemoApp") -> "BaseWorkflowManager | None":
"""The workflow manager, or None while it is still coming up.

``app.workflow`` raises until the controller reports READY, so a command
that touches it during background initialization would otherwise surface as
the generic "Error executing command" from ``BaseCLIApp._handle_command``.
Built-in commands use exactly this shape (see ``SessionsCommand``); the
caller is expected to warn and return.
"""
try:
return app.workflow
except (RuntimeError, AttributeError):
return None


class MemoryCommand(Command):
"""Show persistent memory contents."""

Expand All @@ -27,7 +44,15 @@ def __init__(self) -> None:
)

async def execute(self, args: str, app: "ResearchDemoApp") -> None:
memory_store = app.workflow.memory_manager if app.workflow else None
workflow = _ready_workflow(app)
if workflow is None:
app.session.add_warning(
"Memory is not available yet — the workflow is still "
"initializing. Try /memory again in a moment."
)
return

memory_store = workflow.memory_manager

table = Table(title="Persistent Memory", show_header=True)
table.add_column("ID", style="dim", width=8)
Expand Down Expand Up @@ -123,9 +148,12 @@ async def execute(self, args: str, app: "ResearchDemoApp") -> None:
from agentic_cli.knowledge_base.manager import BackfillAlreadyRunning
from agentic_cli.workflow.service_registry import set_service_registry

workflow = app.workflow
workflow = _ready_workflow(app)
if workflow is None:
app.session.add_error("Workflow not initialized")
app.session.add_warning(
"The knowledge base is not available yet — the workflow is "
"still initializing. Try /kb-backfill again in a moment."
)
return

project_kb = workflow.kb_manager
Expand Down
20 changes: 14 additions & 6 deletions examples/research_demo/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ class ResearchDemoSettings(BaseSettings):

Demonstrates all P0/P1 features with memory, planning, and HITL.

Settings are loaded from (in order of precedence):
1. Environment variables (RESEARCH_DEMO_* prefix)
2. Project config (./settings.json)
3. User config (~/.research_demo/settings.json)
4. .env file (~/.research_demo/.env)
5. Default values
Settings are loaded from (highest precedence first):

1. Constructor arguments
2. Environment variables (``RESEARCH_DEMO_*`` prefix)
3. Project config ``./.research_demo/settings.json`` — **untrusted**: a
cloned repo can ship one, so only an explicit allowlist of benign keys
is honoured and security-sensitive keys are dropped with a warning
4. User config ``~/.research_demo/settings.json`` (trusted)
5. ``~/.research_demo/.env`` — trusted because the path is absolute; a
cwd-relative ``.env`` would be filtered like the project config, so put
API keys here or in real environment variables
6. Field defaults (including the ones set in ``model_post_init`` below)

JSON sources are only consulted when the file exists.
"""

model_config = SettingsConfigDict(
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
# The console smoke tests drive the real prompt_toolkit application over a
# pty; it is available transitively today (via ipykernel), which is not a
# guarantee, so declare it.
"pexpect>=4.9.0",
]
kb = [
"torch>=2.2.0",
Expand Down Expand Up @@ -91,6 +95,7 @@ markers = [
"llm: tests that require real LLM API calls (deselect with -m 'not llm')",
"docker: tests that require a real container runtime (select with -m docker; set SANDBOX_REQUIRE_DOCKER=1 to fail instead of skip when absent)",
"latex: tests that require a host TeX engine (select with -m latex; set LATEX_REQUIRE=1 to fail instead of skip when absent)",
"wheel: acceptance tests that build and install the wheel (select with -m wheel; excluded from the offline suite because they download dependencies)",
]

[tool.ruff]
Expand Down
10 changes: 10 additions & 0 deletions src/agentic_cli/workflow/adk/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,9 +751,19 @@ def _init_plugins(self) -> list:
List of BasePlugin instances to pass to Runner(plugins=...).
"""
from agentic_cli.workflow.adk.task_progress_plugin import TaskProgressPlugin
from agentic_cli.workflow.adk.transfer_tool_description import (
TransferToolDescriptionPlugin,
)

plugins: list = [PermissionPlugin()]

# ADK's generated description for its own transfer tool tells the model
# to call `TransferToAgentTool` — a name that does not exist as a tool.
# Corrected on the prepared request; a no-op once ADK ships a fixed
# docstring. See transfer_tool_description for why this is safe.
self._transfer_description_plugin = TransferToolDescriptionPlugin()
plugins.append(self._transfer_description_plugin)

# Task progress tracking via ToolContext.state
self._task_progress_plugin = TaskProgressPlugin()
plugins.append(self._task_progress_plugin)
Expand Down
25 changes: 24 additions & 1 deletion src/agentic_cli/workflow/adk/permission_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,34 @@ class name, and never equality.
pass


def _native_transfer_tool_type() -> type | None:
"""ADK's exact ``TransferToAgentTool`` class, or None if unavailable."""
try:
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
except ImportError: # pragma: no cover - ADK always ships it today
return None
return TransferToAgentTool


# ADK's own function-tool types: their documented contract is to call exactly
# ``self.func``, so the callable's identity is the tool's identity. Matched by
# exact type — a subclass may override ``run_async`` and run something else
# while still advertising a genuine ``func``.
_TRUSTED_FUNCTION_TOOL_TYPES = (FunctionTool, LongRunningFunctionTool)
#
# ``TransferToAgentTool`` is in the list for the same reason and on the same
# terms. ADK auto-injects it into any agent with ``sub_agents``, and it is a
# ``FunctionTool`` *subclass*, so an exact-type check on ``FunctionTool`` alone
# denied the built-in routing tool as unregistered — delegation could not work
# at all, even with permissions disabled. It is safe to add because ADK
# constructs it as ``super().__init__(func=transfer_to_agent)`` and overrides
# only ``_get_declaration`` (to add the agent-name enum), never ``run_async``:
# what it invokes is still exactly ``self.func``. Listing the exact class keeps
# every other ``FunctionTool`` subclass denied.
_TRUSTED_FUNCTION_TOOL_TYPES = tuple(
t
for t in (FunctionTool, LongRunningFunctionTool, _native_transfer_tool_type())
if t is not None
)


def _trusted_wrapped_callable(tool: "BaseTool") -> Any | None:
Expand Down
Loading
Loading