Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .github/workflows/agentex-tutorials-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ jobs:
# command line below is a different variable, read only by the tutorials' own test
# files, and never reaches the SDK.
AGENTEX_BASE_URL: http://localhost:5003
# `agentex agents run` listens on 127.0.0.1 by default. The AgentEx server container
# dials agents at host.docker.internal, which host-gateway maps to the Docker bridge,
# not loopback, so agents here must listen on every interface.
AGENTEX_AGENT_HOST: 0.0.0.0
run: |
echo "Testing tutorial: ${{ matrix.tutorial }}"
AGENTEX_API_BASE_URL="http://localhost:5003" \
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ asyncio.run(main())

Functionality between the synchronous and asynchronous clients is otherwise identical.

## Running agents locally

`agentex agents run --manifest manifest.yaml` serves the agent on `127.0.0.1`, because the local server has no
authentication. If the AgentEx server reaches your agent from a Linux container through `host-gateway`, or from another
machine, pass `--host 0.0.0.0` or set `AGENTEX_AGENT_HOST=0.0.0.0`. Anyone who can reach that port can then call the agent.

## Debugging

AgentEx provides built-in debugging support for **temporal projects** during local development.
Expand Down
2 changes: 1 addition & 1 deletion examples/tutorials/00_sync/060_claude_code/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ RUN uv pip install --system --upgrade pip setuptools wheel

# Install the claude CLI (requires Node.js)
# NOTE: live runs require ANTHROPIC_API_KEY in the environment.
RUN npm install -g @anthropic-ai/claude-code || true
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true

ENV UV_HTTP_TIMEOUT=1000

Expand Down
38 changes: 36 additions & 2 deletions examples/tutorials/00_sync/060_claude_code/project/acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
from __future__ import annotations

import os
import stat
import asyncio
import hashlib
import tempfile
from typing import AsyncIterator, AsyncGenerator
from pathlib import Path

from dotenv import load_dotenv

Expand Down Expand Up @@ -45,8 +49,35 @@

acp = FastACP.create(acp_type="sync")

# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools,
# denies anything that would need approval (such as reads outside its working
# directory), and ignores the developer's own Claude Code settings, hooks,
# plugins and MCP servers. Widen this only if every caller is trusted.
CLAUDE_POLICY_ARGS = (
"--tools", "Read,Glob,Grep",
"--permission-mode", "dontAsk",
"--setting-sources", "",
"--strict-mcp-config",
)
# The only variables the CLI sees; USER lets it find a macOS keychain login.
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"


def _claude_workdir(task_id: str) -> Path:
"""Per-task scratch directory for the CLI, outside this project (which holds .env)."""
CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True)
root = CLAUDE_WORKDIR_ROOT.lstat()
# The temp dir can be shared (Linux /tmp): refuse a root another account made or linked.
if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077):
raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user")
# Hashed because the task id comes from the request and must not steer the path.
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
workdir.mkdir(mode=0o700, exist_ok=True)
return workdir


async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]:
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.

This is a seam: tests replace it with a fake async iterator of
Expand All @@ -58,6 +89,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
"--output-format",
"stream-json",
"--verbose",
*CLAUDE_POLICY_ARGS,
cwd=_claude_workdir(task_id),
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down Expand Up @@ -132,6 +166,6 @@ async def handle_message_send(
trace_id=task_id,
parent_span_id=turn_span.id if turn_span else None,
)
turn = ClaudeCodeTurn(_spawn_claude(prompt))
turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id))
async for event in emitter.yield_turn(turn):
yield event
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \

RUN uv pip install --system --upgrade pip setuptools wheel

RUN npm install -g @anthropic-ai/claude-code || true
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true

ENV UV_HTTP_TIMEOUT=1000

Expand Down
38 changes: 36 additions & 2 deletions examples/tutorials/10_async/00_base/130_claude_code/project/acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
from __future__ import annotations

import os
import stat
import asyncio
import hashlib
import tempfile
from typing import AsyncIterator
from pathlib import Path

from dotenv import load_dotenv

Expand Down Expand Up @@ -46,8 +50,35 @@
config=AsyncACPConfig(type="base"),
)

# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools,
# denies anything that would need approval (such as reads outside its working
# directory), and ignores the developer's own Claude Code settings, hooks,
# plugins and MCP servers. Widen this only if every caller is trusted.
CLAUDE_POLICY_ARGS = (
"--tools", "Read,Glob,Grep",
"--permission-mode", "dontAsk",
"--setting-sources", "",
"--strict-mcp-config",
)
# The only variables the CLI sees; USER lets it find a macOS keychain login.
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"


def _claude_workdir(task_id: str) -> Path:
"""Per-task scratch directory for the CLI, outside this project (which holds .env)."""
CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True)
root = CLAUDE_WORKDIR_ROOT.lstat()
# The temp dir can be shared (Linux /tmp): refuse a root another account made or linked.
if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077):
raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user")
# Hashed because the task id comes from the request and must not steer the path.
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
workdir.mkdir(mode=0o700, exist_ok=True)
return workdir


async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]:
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.

Injectable seam: tests monkeypatch this with a fake async iterator of
Expand All @@ -59,6 +90,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
"--output-format",
"stream-json",
"--verbose",
*CLAUDE_POLICY_ARGS,
cwd=_claude_workdir(task_id),
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down Expand Up @@ -138,7 +172,7 @@ async def handle_task_event_send(params: SendEventParams):
trace_id=task_id,
parent_span_id=turn_span.id if turn_span else None,
)
turn = ClaudeCodeTurn(_spawn_claude(prompt))
turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id))
result = await emitter.auto_send_turn(turn)
if turn_span:
turn_span.output = {"final_text": result.final_text}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \

RUN uv pip install --system --upgrade pip setuptools wheel

RUN npm install -g @anthropic-ai/claude-code || true
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true

ENV UV_HTTP_TIMEOUT=1000

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@

from __future__ import annotations

import os
import stat
import asyncio
import hashlib
import tempfile
from typing import Any, AsyncIterator
from pathlib import Path
from datetime import datetime

from temporalio import activity
Expand Down Expand Up @@ -48,7 +53,39 @@ class RunClaudeCodeTurnResult(BaseModel):
session_id: str | None = None


async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]:
# Whoever can reach this agent's ACP writes the prompt, so the CLI gets read-only
# tools, denies anything that would need approval (such as reads outside its
# working directory), and ignores the developer's own Claude Code settings,
# hooks, plugins and MCP servers. Widen this only if every caller is trusted.
CLAUDE_POLICY_ARGS = (
"--tools", "Read,Glob,Grep",
"--permission-mode", "dontAsk",
"--setting-sources", "",
"--strict-mcp-config",
)
# The only variables the CLI sees; USER lets it find a macOS keychain login.
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"


def _claude_workdir(task_id: str) -> Path:
"""Per-task scratch directory for the CLI, outside this project (which holds .env).

Stable across turns: the CLI files sessions by working directory, so
``-r <session_id>`` only finds the previous turn from the same directory.
"""
CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True)
root = CLAUDE_WORKDIR_ROOT.lstat()
# The temp dir can be shared (Linux /tmp): refuse a root another account made or linked.
if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077):
raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user")
# Hashed because the task id comes from the request and must not steer the path.
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
workdir.mkdir(mode=0o700, exist_ok=True)
return workdir


async def _spawn_claude(prompt: str, task_id: str, session_id: str | None = None) -> AsyncIterator[str]:
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.

Pass ``session_id`` to resume a previous Claude Code session (multi-turn
Expand All @@ -63,12 +100,15 @@ async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIter
"--output-format",
"stream-json",
"--verbose",
*CLAUDE_POLICY_ARGS,
]
if session_id:
cmd.extend(["-r", session_id])

proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=_claude_workdir(task_id),
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
Expand Down Expand Up @@ -133,7 +173,7 @@ async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any
trace_id=params.trace_id,
parent_span_id=params.parent_span_id,
)
turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id))
turn = ClaudeCodeTurn(_spawn_claude(params.prompt, params.task_id, session_id=params.session_id))
result = await emitter.auto_send_turn(turn, created_at=params.created_at)

return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump()
10 changes: 9 additions & 1 deletion src/agentex/lib/cli/commands/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
validate_namespace,
check_and_switch_cluster_context,
)
from agentex.lib.cli.handlers.run_handlers import DEFAULT_ACP_HOST
from agentex.lib.sdk.config.agent_manifest import load_agent_manifest
from agentex.lib.cli.handlers.agent_handlers import (
run_agent,
Expand Down Expand Up @@ -277,6 +278,13 @@ def run(
debug_acp: bool = typer.Option(False, help="Enable debug mode for ACP server only"),
debug_port: int = typer.Option(5678, help="Port for remote debugging (worker uses this, ACP uses port+1)"),
wait_for_debugger: bool = typer.Option(False, help="Wait for debugger to attach before starting"),
host: str = typer.Option(
DEFAULT_ACP_HOST,
envvar="AGENTEX_AGENT_HOST",
help="Interface the local ACP server listens on. It has no authentication, so use 0.0.0.0 only when "
"something off this machine must reach it, such as an agentex server in a Linux container that "
"dials host.docker.internal through host-gateway.",
),
) -> None:
"""
Run an agent locally from the given manifest.
Expand Down Expand Up @@ -326,7 +334,7 @@ def run(
console.print("[yellow]⏳ Processes will wait for debugger attachment[/yellow]")

try:
run_agent(manifest_path=manifest, debug_config=debug_config)
run_agent(manifest_path=manifest, debug_config=debug_config, host=host)
except Exception as e:
typer.echo(f"Error running agent: {str(e)}", err=True)
logger.exception("Error running agent")
Expand Down
9 changes: 5 additions & 4 deletions src/agentex/lib/cli/debug/debug_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ async def start_temporal_worker_debug(


async def start_acp_server_debug(
acp_path: Path,
port: int,
acp_path: Path,
port: int,
env: Dict[str, str],
debug_config: DebugConfig
debug_config: DebugConfig,
host: str = "127.0.0.1",
):
"""Start ACP server with debug support"""

Expand Down Expand Up @@ -103,7 +104,7 @@ async def start_acp_server_debug(
"--port",
str(port),
"--host",
"0.0.0.0",
host,
# Note: No --reload flag when debugging
]

Expand Down
6 changes: 3 additions & 3 deletions src/agentex/lib/cli/handlers/agent_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from agentex.lib.cli.debug import DebugConfig
from agentex.lib.utils.logging import make_logger
from agentex.lib.cli.handlers.run_handlers import RunError, run_agent as _run_agent
from agentex.lib.cli.handlers.run_handlers import DEFAULT_ACP_HOST, RunError, run_agent as _run_agent
from agentex.lib.sdk.config.agent_manifest import BuildContextManager, load_agent_manifest, build_context_manager

logger = make_logger(__name__)
Expand Down Expand Up @@ -138,7 +138,7 @@ def build_agent(
return image_name


def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None):
def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None, host: str = DEFAULT_ACP_HOST):
"""Run an agent locally from the given manifest"""
import sys
import signal
Expand All @@ -164,7 +164,7 @@ def signal_handler(signum, _frame):
signal.signal(signal.SIGTERM, signal_handler)

try:
asyncio.run(_run_agent(manifest_path, debug_config))
asyncio.run(_run_agent(manifest_path, debug_config, host))
except KeyboardInterrupt:
logger.info("Shutdown completed.")
sys.exit(0)
Expand Down
Loading
Loading