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
4 changes: 2 additions & 2 deletions .agentception/agent-command-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ reads this policy before executing any command.

---

## How Cursor's Three Tiers Actually Work
## How the Three Tiers Work (Cursor as MCP client)

Cursor does not have a formal graylist or blacklist. Here is how the three tiers
map to Cursor's actual mechanisms:
map to Cursor's mechanisms when it is used as the MCP client:

| Policy tier | Cursor mechanism | What happens |
|-------------|-----------------|--------------|
Expand Down
2 changes: 1 addition & 1 deletion .agentception/agent-conductor.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ STEP 7 — REMINDER GATE:

### What to do next
Re-run the conductor by pasting the kickoff prompt from \`.agentception/agent-conductor.md\`
into a new Cursor composer window rooted in the conductor worktree.
as a new agent session rooted in the conductor worktree.

### Open issues by phase
$(for phase in "${ALL_PHASES[@]}"; do
Expand Down
8 changes: 4 additions & 4 deletions .agentception/agent-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
> 1. Query GitHub for the current batch label to get your canonical issue set — **never use a hardcoded list**.
> 2. Pull `dev` to confirm it is up to date.
> 3. Call the `build_spawn_child` MCP tool once per issue to create the worktree, persist context to the DB, and auto-acknowledge the run.
> 4. Launch one sub-agent per worktree using the **Task tool** (preferred — allows unlimited parallel agents) or a Cursor composer window rooted in that worktree.
> 4. Launch one sub-agent per worktree using the **Task tool** (preferred — allows unlimited parallel agents) or an agent session rooted in that worktree.
> 5. Report back once all sub-agents have been launched.
>
> **You do NOT:**
Expand Down Expand Up @@ -266,8 +266,8 @@ git worktree list
```

After running this, launch one agent per worktree using the **Task tool**
(preferred — no limit on simultaneous agents) or a Cursor composer window
rooted in each `issue-<N>` directory.
(preferred — no limit on simultaneous agents) or an agent session rooted
in each `issue-<N>` directory.

---

Expand Down Expand Up @@ -298,7 +298,7 @@ If you see "No module named X", you are on the host. Stop. Use `docker compose e

## Environment (agents read this first)

**You are running inside a Cursor worktree.** Your working directory is NOT the main repo.
**You are running inside a git worktree.** Your working directory is NOT the main repo.

```bash
# Derive paths — run these at the start of your session
Expand Down
2 changes: 1 addition & 1 deletion .agentception/pipeline-howto.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ One file per worktree. Use the TOML format above. Key fields:

### Step 4 — Launch the two managers (skip the CTO for simple waves)

Open two Cursor composer windows or call the Task tool twice simultaneously:
Call the Task tool twice simultaneously (or open two agent sessions in parallel):

**QA Coordinator prompt:**
```
Expand Down
2 changes: 1 addition & 1 deletion .agentception/roles/architect.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ This repository is a single standalone service:

```
agentception/ # AgentCeption — FastAPI + HTMX + Alpine (port 1337)
# mcp/ # MCP server for Cursor/Claude tool integration
# mcp/ # MCP server for MCP client tool integration
```

Architecture layers:
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ When you remove something, remove it completely: the implementation, the tests f
- API contract changes (SSE event shapes, tool schemas, endpoint signatures).
- Architecture changes (new layers, new services, new execution paths).
- Security model changes.
- Changes that affect agents running in Cursor worktrees.
- Changes that affect agents running in git worktrees.

---

Expand All @@ -95,13 +95,13 @@ When facing ambiguity:

## Branch Discipline — Absolute Rule

**`dev` and `main` are read-only for all agents and all Cursor sessions. Every piece of work — one line or a thousand — happens on a branch or in a worktree.**
**`dev` and `main` are read-only for all agents and all interactive development sessions. Every piece of work — one line or a thousand — happens on a branch or in a worktree.**

### AgentCeption pipeline (worktree)

All agent work runs inside a git worktree created from `origin/dev` at dispatch time. The PR is opened from the worktree branch. The main repo's `dev` branch is never modified by an agent. When the agent finishes, it removes its own worktree.

### Cursor / interactive sessions (feature branch)
### Interactive development sessions (feature branch)

Every task follows this complete lifecycle — no step is optional:

Expand Down Expand Up @@ -190,7 +190,7 @@ When your changes affect another agent's domain, produce a **handoff prompt** de

## GitHub interactions — MCP first

The `user-github` MCP server (officially maintained by GitHub) is available in every Cursor session. **Always prefer MCP tools over `gh` CLI for GitHub operations.** MCP calls are typed, structured, and composable; `gh` is a last resort for operations not yet covered by the server.
The `user-github` MCP server (officially maintained by GitHub) is available in every interactive development session. **Always prefer MCP tools over `gh` CLI for GitHub operations.** MCP calls are typed, structured, and composable; `gh` is a last resort for operations not yet covered by the server.

| Operation | MCP tool |
|-----------|----------|
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@ See [docs/guides/setup.md](docs/guides/setup.md) for the full first-run walkthro

---

## MCP Integration (Cursor / Claude)
## MCP Integration

AgentCeption exposes an MCP server so Cursor and Claude can invoke tools directly:
AgentCeption exposes an MCP server that any MCP-compatible client can use:

```json
{
Expand All @@ -159,7 +159,7 @@ See [docs/guides/integrate.md](docs/guides/integrate.md) for the full tool refer
| [Setup](docs/guides/setup.md) | First-run, Docker, environment variables |
| [Local LLM / Ollama](docs/guides/local-llm.md) | Running agents on local hardware with Ollama (macOS, Linux, Windows) |
| [Local LLM Scaling](docs/guides/local-llm-scaling.md) | Multi-agent concurrency and LiteLLM proxy |
| [MCP Integration](docs/guides/integrate.md) | Cursor / Claude tool integration |
| [MCP Integration](docs/guides/integrate.md) | MCP client tool integration |
| [Dispatching Agents](docs/guides/dispatch.md) | How to launch, monitor, and cancel agent runs |
| [Developer Workflow](docs/guides/developer-workflow.md) | Bind mounts, mypy, tests, build pipeline |
| [Contributing](docs/guides/contributing.md) | Branch conventions, PR process, commit style |
Expand Down
9 changes: 4 additions & 5 deletions agentception/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
class TaskRunnerChoice(str, enum.Enum):
"""Task runner backend for agent execution.

Agent tasks are executed by the Cursor-free loop (direct API calls).
Currently the only supported value is ``anthropic`` (default).
Agent tasks are executed by the server-side agent loop (direct LLM API
calls). Currently the only supported value is ``anthropic`` (default).
"""
anthropic = "anthropic"

Expand Down Expand Up @@ -338,7 +338,7 @@ def effective_local_model_agent(self) -> str:
"""Task runner backend for agent execution.

Set via ``AC_TASK_RUNNER`` env var. Valid value: ``anthropic`` (default).
Agent tasks run via the Cursor-free loop (direct Anthropic or local LLM API).
Agent tasks run via the server-side agent loop (direct Anthropic or local LLM API).
"""
ac_min_turn_delay_secs: float = 0.5
"""Minimum seconds between consecutive LLM calls in the agent loop.
Expand Down Expand Up @@ -425,8 +425,7 @@ def ac_dir(self) -> Path:
"""Canonical path to the ``.agentception/`` directory at the repo root.

All AgentCeption-owned config files (roles, prompts, pipeline-config,
dispatcher prompt, etc.) live here — not in ``.cursor/``, which belongs
to the IDE.
dispatcher prompt, etc.) live here — not in the IDE config directory.
"""
return self.repo_dir / ".agentception"

Expand Down
241 changes: 241 additions & 0 deletions agentception/mcp/elicitation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
from __future__ import annotations

"""MCP elicitation — server-initiated human-in-the-loop requests.

Implements the ``elicitation/create`` protocol from MCP 2025-11-25. The
server sends an ``elicitation/create`` JSON-RPC request to a connected
dashboard session and blocks until the human responds, declines, or the
timeout expires.

``request_human_input`` is the MCP *tool* implementation — agents call it via
``tools/call`` when they need a decision that only the human can provide:
architectural choices, credential approval, branching strategy, etc.

Flow
----
1. Agent calls ``request_human_input(message=..., fields=[...])``.
2. Tool selects the first dashboard session that declared
``elicitation_form`` capability (MCP session in the browser).
3. Server puts an ``elicitation/create`` JSON-RPC request into the session's
outbound queue; the SSE stream delivers it to the dashboard.
4. Dashboard renders a form modal. Human fills it in and clicks Submit.
5. Dashboard POSTs the JSON-RPC response back to ``POST /api/mcp``.
6. HTTP route calls :func:`~agentception.mcp.sessions.McpSessionStore.resolve_response`
which resolves the pending :class:`asyncio.Future`.
7. :func:`send_form_elicitation` returns the result to the tool call handler.
8. Agent receives a dict with ``action`` and (optionally) ``content``.
"""

import asyncio
import logging
import secrets

from agentception.mcp.sessions import McpSession, get_store
from agentception.mcp.types import ElicitationField, ElicitationResult
from agentception.types import JsonValue

logger = logging.getLogger(__name__)

_DEFAULT_TIMEOUT: float = 300.0 # seconds before the tool auto-cancels


def _build_json_schema(fields: list[ElicitationField]) -> dict[str, JsonValue]:
"""Convert the simplified ``ElicitationField`` list to a JSON Schema.

The spec requires a flat ``"object"`` schema with only primitive-typed
properties (``string``, ``number``, ``integer``, ``boolean``). We pass
the schema verbatim in the ``elicitation/create`` params so the
dashboard can render an appropriate form.
"""
properties: dict[str, JsonValue] = {}
required_keys: list[JsonValue] = []

for field in fields:
prop: dict[str, JsonValue] = {"type": field["type"]}

if "title" in field:
prop["title"] = field["title"]
if "description" in field:
prop["description"] = field["description"]
if "default" in field:
prop["default"] = field["default"]

ftype = field.get("type")
if ftype == "string":
if "enum" in field:
prop["enum"] = list(field["enum"])
if "format" in field:
prop["format"] = field["format"]
if ftype in ("number", "integer"):
if "minimum" in field:
prop["minimum"] = field["minimum"]
if "maximum" in field:
prop["maximum"] = field["maximum"]

properties[field["name"]] = prop
if field.get("required", False):
required_keys.append(field["name"])

schema: dict[str, JsonValue] = {
"type": "object",
"properties": properties,
}
if required_keys:
schema["required"] = required_keys
return schema


async def send_form_elicitation(
session: McpSession,
message: str,
schema: dict[str, JsonValue],
timeout_seconds: float = _DEFAULT_TIMEOUT,
) -> ElicitationResult:
"""Send ``elicitation/create`` (form mode) to *session* and await reply.

The server generates a unique ``id`` for the JSON-RPC request, registers
a :class:`asyncio.Future` in ``session.pending``, then puts the request
into the session's outbound queue. The SSE stream delivers it to the
dashboard within milliseconds.

The coroutine suspends until either:
- The client POSTs a JSON-RPC response with the matching ``id``, or
- *timeout_seconds* elapses (raises :class:`asyncio.TimeoutError`).

Raises
------
asyncio.TimeoutError
When the human does not respond within *timeout_seconds*.
"""
elicitation_id = secrets.token_urlsafe(16)
loop = asyncio.get_running_loop()
fut: asyncio.Future[dict[str, JsonValue]] = loop.create_future()
session.pending[elicitation_id] = fut

rpc_request: dict[str, JsonValue] = {
"jsonrpc": "2.0",
"id": elicitation_id,
"method": "elicitation/create",
"params": {
"mode": "form",
"message": message,
"requestedSchema": schema,
},
}
await session.outbound.put(rpc_request)
logger.info(
"🙋 elicitation/create → session %s (elicitation_id=%s)",
session.session_id[:8],
elicitation_id[:8],
)

try:
raw: dict[str, JsonValue] = await asyncio.wait_for(
asyncio.shield(fut), timeout=float(timeout_seconds)
)
except asyncio.TimeoutError:
session.pending.pop(elicitation_id, None)
if not fut.done():
fut.cancel()
raise

raw_content = raw.get("content")
content: dict[str, JsonValue] = (
{k: v for k, v in raw_content.items()}
if isinstance(raw_content, dict)
else {}
)

action = str(raw.get("action", "cancel"))
result = ElicitationResult(action=action)
if action == "accept" and content:
result["content"] = content
return result


async def request_human_input(
message: str,
fields: list[ElicitationField],
run_id: str | None = None,
timeout_seconds: float = _DEFAULT_TIMEOUT,
) -> dict[str, JsonValue]:
"""MCP tool: block until a human operator provides structured input.

Selects the first session with ``elicitation_form`` capability, sends an
``elicitation/create`` request, and returns the human's response.

Returns a dict with:
``action`` — ``"accept"`` | ``"decline"`` | ``"cancel"`` |
``"timeout"`` | ``"no_client"``
``content`` — submitted form data (only when action == "accept")
``message`` — human-readable outcome summary

When no elicitation-capable session is connected (no browser tab open on
Mission Control) the tool returns immediately with ``action="no_client"``
rather than blocking indefinitely.
"""
store = get_store()
sessions = store.elicitation_sessions(mode="form")

if not sessions:
logger.warning(
"⚠️ request_human_input: no elicitation-capable session (run=%r)",
run_id,
)
return {
"action": "no_client",
"message": (
"No dashboard session with elicitation capability is connected. "
"Open Mission Control in your browser to enable real-time "
"human-in-the-loop input for running agents."
),
}

session = sessions[0]
schema = _build_json_schema(fields)
ctx = f" (run: {run_id})" if run_id else ""
logger.info(
"🙋 request_human_input%s → session %s",
ctx,
session.session_id[:8],
)

try:
result = await send_form_elicitation(
session,
message,
schema,
timeout_seconds=timeout_seconds,
)
except asyncio.TimeoutError:
logger.warning(
"⚠️ request_human_input: timeout after %ds (session=%s run=%r)",
timeout_seconds,
session.session_id[:8],
run_id,
)
return {
"action": "timeout",
"message": (
f"Human did not respond within {timeout_seconds}s. "
"Proceed with your best judgment or configured defaults."
),
}

action = result["action"]
out: dict[str, JsonValue] = {"action": action}
if action == "accept":
out["content"] = result.get("content") or {}
out["message"] = "Human provided input — proceed with the submitted values."
elif action == "decline":
out["message"] = "Human declined to provide input."
else:
out["message"] = "Human dismissed the request without acting."

logger.info(
"✅ request_human_input: action=%r (session=%s run=%r)",
action,
session.session_id[:8],
run_id,
)
return out
Loading
Loading