feat! Claude Code as a CLI agent now supported. Mix and match: spawn claude code from within claude code, or claude code from within codex.
Stay in codex, plan review and fix complicated bugs, then ask it to spawn claude code and implement the plan. This uses your current subscription instead of API tokens.
This commit is contained in:
@@ -22,8 +22,8 @@ Gemini · OpenAI · Anthropic · Grok · Azure · Ollama · OpenRouter · DIAL
|
||||
|
||||
The new **[`clink`](docs/tools/clink.md)** (CLI + Link) tool connects external AI CLIs directly into your workflow:
|
||||
|
||||
- **Connect external CLIs** like [Gemini CLI](https://github.com/google-gemini/gemini-cli) and [Codex CLI](https://github.com/openai/codex) directly into your workflow
|
||||
- **Codex Subagents** - Launch isolated Codex instances from _within_ Codex itself! Offload heavy tasks (code reviews, bug hunting) to fresh contexts while your main session's context window remains unpolluted. Each subagent returns only final results.
|
||||
- **Connect external CLIs** like [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), and [Claude Code](https://www.anthropic.com/claude-code) directly into your workflow
|
||||
- **CLI Subagents** - Launch isolated CLI instances from _within_ your current CLI! Claude Code can spawn Codex subagents, Codex can spawn Gemini CLI subagents, etc. Offload heavy tasks (code reviews, bug hunting) to fresh contexts while your main session's context window remains unpolluted. Each subagent returns only final results.
|
||||
- **Context Isolation** - Run separate investigations without polluting your primary workspace
|
||||
- **Role Specialization** - Spawn `planner`, `codereviewer`, or custom role agents with specialized system prompts
|
||||
- **Full CLI Capabilities** - Web search, file inspection, MCP tool access, latest documentation lookups
|
||||
|
||||
@@ -5,17 +5,19 @@ from __future__ import annotations
|
||||
from clink.models import ResolvedCLIClient
|
||||
|
||||
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
|
||||
from .claude import ClaudeAgent
|
||||
from .codex import CodexAgent
|
||||
from .gemini import GeminiAgent
|
||||
|
||||
_AGENTS: dict[str, type[BaseCLIAgent]] = {
|
||||
"gemini": GeminiAgent,
|
||||
"codex": CodexAgent,
|
||||
"claude": ClaudeAgent,
|
||||
}
|
||||
|
||||
|
||||
def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
|
||||
agent_key = client.name.lower()
|
||||
agent_key = (client.runner or client.name).lower()
|
||||
agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
|
||||
return agent_cls(client)
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class BaseCLIAgent:
|
||||
*,
|
||||
role: ResolvedCLIRole,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
files: Sequence[str],
|
||||
images: Sequence[str],
|
||||
) -> AgentOutput:
|
||||
@@ -64,7 +65,7 @@ class BaseCLIAgent:
|
||||
# accepted here only to keep parity with SimpleTool callers.
|
||||
_ = (files, images)
|
||||
# The runner simply executes the configured CLI command for the selected role.
|
||||
command = self._build_command(role=role)
|
||||
command = self._build_command(role=role, system_prompt=system_prompt)
|
||||
env = self._build_environment()
|
||||
|
||||
# Resolve executable path for cross-platform compatibility (especially Windows)
|
||||
@@ -189,7 +190,7 @@ class BaseCLIAgent:
|
||||
output_file_content=output_file_content,
|
||||
)
|
||||
|
||||
def _build_command(self, *, role: ResolvedCLIRole) -> list[str]:
|
||||
def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]:
|
||||
base = list(self.client.executable)
|
||||
base.extend(self.client.internal_args)
|
||||
base.extend(self.client.config_args)
|
||||
|
||||
49
clink/agents/claude.py
Normal file
49
clink/agents/claude.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Claude-specific CLI agent hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from clink.models import ResolvedCLIRole
|
||||
from clink.parsers.base import ParserError
|
||||
|
||||
from .base import AgentOutput, BaseCLIAgent
|
||||
|
||||
|
||||
class ClaudeAgent(BaseCLIAgent):
|
||||
"""Claude CLI agent with system-prompt injection support."""
|
||||
|
||||
def _build_command(self, *, role: ResolvedCLIRole, system_prompt: str | None) -> list[str]:
|
||||
command = list(self.client.executable)
|
||||
command.extend(self.client.internal_args)
|
||||
command.extend(self.client.config_args)
|
||||
|
||||
if system_prompt and "--append-system-prompt" not in self.client.config_args:
|
||||
command.extend(["--append-system-prompt", system_prompt])
|
||||
|
||||
command.extend(role.role_args)
|
||||
return command
|
||||
|
||||
def _recover_from_error(
|
||||
self,
|
||||
*,
|
||||
returncode: int,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
sanitized_command: list[str],
|
||||
duration_seconds: float,
|
||||
output_file_content: str | None,
|
||||
) -> AgentOutput | None:
|
||||
try:
|
||||
parsed = self._parser.parse(stdout, stderr)
|
||||
except ParserError:
|
||||
return None
|
||||
|
||||
return AgentOutput(
|
||||
parsed=parsed,
|
||||
sanitized_command=sanitized_command,
|
||||
returncode=returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_seconds=duration_seconds,
|
||||
parser_name=self._parser.name,
|
||||
output_file_content=output_file_content,
|
||||
)
|
||||
@@ -39,4 +39,10 @@ INTERNAL_DEFAULTS: dict[str, CLIInternalDefaults] = {
|
||||
default_role_prompt="systemprompts/clink/default.txt",
|
||||
runner="codex",
|
||||
),
|
||||
"claude": CLIInternalDefaults(
|
||||
parser="claude_json",
|
||||
additional_args=["--print", "--output-format", "json"],
|
||||
default_role_prompt="systemprompts/clink/default.txt",
|
||||
runner="claude",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ class ResolvedCLIClient(BaseModel):
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
timeout_seconds: int
|
||||
parser: str
|
||||
runner: str | None = None
|
||||
roles: dict[str, ResolvedCLIRole]
|
||||
output_to_file: OutputCaptureConfig | None = None
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import BaseParser, ParsedCLIResponse, ParserError
|
||||
from .claude import ClaudeJSONParser
|
||||
from .codex import CodexJSONLParser
|
||||
from .gemini import GeminiJSONParser
|
||||
|
||||
_PARSER_CLASSES: dict[str, type[BaseParser]] = {
|
||||
CodexJSONLParser.name: CodexJSONLParser,
|
||||
GeminiJSONParser.name: GeminiJSONParser,
|
||||
ClaudeJSONParser.name: ClaudeJSONParser,
|
||||
}
|
||||
|
||||
|
||||
|
||||
111
clink/parsers/claude.py
Normal file
111
clink/parsers/claude.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Parser for Claude CLI JSON output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseParser, ParsedCLIResponse, ParserError
|
||||
|
||||
|
||||
class ClaudeJSONParser(BaseParser):
|
||||
"""Parse stdout produced by `claude --output-format json`."""
|
||||
|
||||
name = "claude_json"
|
||||
|
||||
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
||||
if not stdout.strip():
|
||||
raise ParserError("Claude CLI returned empty stdout while JSON output was expected")
|
||||
|
||||
try:
|
||||
payload: dict[str, Any] = json.loads(stdout)
|
||||
except json.JSONDecodeError as exc: # pragma: no cover - defensive logging
|
||||
raise ParserError(f"Failed to decode Claude CLI JSON output: {exc}") from exc
|
||||
|
||||
metadata = self._build_metadata(payload, stderr)
|
||||
|
||||
result = payload.get("result")
|
||||
content: str = ""
|
||||
if isinstance(result, str):
|
||||
content = result.strip()
|
||||
elif isinstance(result, list):
|
||||
# Some CLI flows may emit a list of strings; join them conservatively.
|
||||
joined = [part.strip() for part in result if isinstance(part, str) and part.strip()]
|
||||
content = "\n".join(joined)
|
||||
|
||||
if content:
|
||||
return ParsedCLIResponse(content=content, metadata=metadata)
|
||||
|
||||
message = self._extract_message(payload)
|
||||
if message:
|
||||
return ParsedCLIResponse(content=message, metadata=metadata)
|
||||
|
||||
stderr_text = stderr.strip()
|
||||
if stderr_text:
|
||||
metadata.setdefault("stderr", stderr_text)
|
||||
return ParsedCLIResponse(
|
||||
content="Claude CLI returned no textual result. Raw stderr was preserved for troubleshooting.",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
raise ParserError("Claude CLI response did not contain a textual result")
|
||||
|
||||
def _build_metadata(self, payload: dict[str, Any], stderr: str) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {
|
||||
"raw": payload,
|
||||
"is_error": bool(payload.get("is_error")),
|
||||
}
|
||||
|
||||
type_field = payload.get("type")
|
||||
if isinstance(type_field, str):
|
||||
metadata["type"] = type_field
|
||||
subtype_field = payload.get("subtype")
|
||||
if isinstance(subtype_field, str):
|
||||
metadata["subtype"] = subtype_field
|
||||
|
||||
duration_ms = payload.get("duration_ms")
|
||||
if isinstance(duration_ms, (int, float)):
|
||||
metadata["duration_ms"] = duration_ms
|
||||
api_duration = payload.get("duration_api_ms")
|
||||
if isinstance(api_duration, (int, float)):
|
||||
metadata["duration_api_ms"] = api_duration
|
||||
|
||||
usage = payload.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
metadata["usage"] = usage
|
||||
|
||||
model_usage = payload.get("modelUsage")
|
||||
if isinstance(model_usage, dict) and model_usage:
|
||||
metadata["model_usage"] = model_usage
|
||||
first_model = next(iter(model_usage.keys()))
|
||||
metadata["model_used"] = first_model
|
||||
|
||||
permission_denials = payload.get("permission_denials")
|
||||
if isinstance(permission_denials, list) and permission_denials:
|
||||
metadata["permission_denials"] = permission_denials
|
||||
|
||||
session_id = payload.get("session_id")
|
||||
if isinstance(session_id, str) and session_id:
|
||||
metadata["session_id"] = session_id
|
||||
uuid_field = payload.get("uuid")
|
||||
if isinstance(uuid_field, str) and uuid_field:
|
||||
metadata["uuid"] = uuid_field
|
||||
|
||||
stderr_text = stderr.strip()
|
||||
if stderr_text:
|
||||
metadata.setdefault("stderr", stderr_text)
|
||||
|
||||
return metadata
|
||||
|
||||
def _extract_message(self, payload: dict[str, Any]) -> str | None:
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
|
||||
error_field = payload.get("error")
|
||||
if isinstance(error_field, dict):
|
||||
error_message = error_field.get("message")
|
||||
if isinstance(error_message, str) and error_message.strip():
|
||||
return error_message.strip()
|
||||
|
||||
return None
|
||||
@@ -149,6 +149,8 @@ class ClinkRegistry:
|
||||
f"CLI '{raw.name}' must define a parser either in configuration or internal defaults"
|
||||
)
|
||||
|
||||
runner_name = internal_defaults.runner if internal_defaults else None
|
||||
|
||||
env = self._merge_env(raw, internal_defaults)
|
||||
working_dir = self._resolve_optional_path(raw.working_dir, source_path.parent)
|
||||
roles = self._resolve_roles(raw, internal_defaults, source_path)
|
||||
@@ -163,6 +165,7 @@ class ClinkRegistry:
|
||||
env=env,
|
||||
timeout_seconds=int(timeout_seconds),
|
||||
parser=parser_name,
|
||||
runner=runner_name,
|
||||
roles=roles,
|
||||
output_to_file=output_to_file,
|
||||
working_dir=working_dir,
|
||||
|
||||
25
conf/cli_clients/claude.json
Normal file
25
conf/cli_clients/claude.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "claude",
|
||||
"command": "claude",
|
||||
"additional_args": [
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
"--model",
|
||||
"sonnet"
|
||||
],
|
||||
"env": {},
|
||||
"roles": {
|
||||
"default": {
|
||||
"prompt_path": "systemprompts/clink/default.txt",
|
||||
"role_args": []
|
||||
},
|
||||
"planner": {
|
||||
"prompt_path": "systemprompts/clink/default_planner.txt",
|
||||
"role_args": []
|
||||
},
|
||||
"codereviewer": {
|
||||
"prompt_path": "systemprompts/clink/default_codereviewer.txt",
|
||||
"role_args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ The subagent:
|
||||
- Returns **only the final security report** (not intermediate steps)
|
||||
- Your main session stays **laser-focused** on debugging
|
||||
|
||||
**Works with any supported CLI**: Codex can spawn Codex / Claude Code / Gemini CLI subagents, or mix and match between different CLIs.
|
||||
|
||||
---
|
||||
|
||||
### Cross-CLI Orchestration
|
||||
@@ -76,7 +78,7 @@ You can make your own custom roles in `conf/cli_clients/` or tweak any of the sh
|
||||
## Tool Parameters
|
||||
|
||||
- `prompt`: Your question or task for the external CLI (required)
|
||||
- `cli_name`: Which CLI to use - `gemini` (default), or add your own in `conf/cli_clients/`
|
||||
- `cli_name`: Which CLI to use - `gemini` (default), `claude`, `codex`, or add your own in `conf/cli_clients/`
|
||||
- `role`: Preset role - `default`, `planner`, `codereviewer` (default: `default`)
|
||||
- `files`: Optional file paths for context (references only, CLI opens files itself)
|
||||
- `images`: Optional image paths for visual context
|
||||
@@ -159,6 +161,7 @@ Each preset points to role-specific prompts in `systemprompts/clink/`. Duplicate
|
||||
|
||||
Ensure the relevant CLI is installed and configured:
|
||||
|
||||
- [Claude Code](https://www.anthropic.com/claude-code)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
||||
- [Codex CLI](https://docs.sourcegraph.com/codex)
|
||||
|
||||
|
||||
111
tests/test_clink_claude_agent.py
Normal file
111
tests/test_clink_claude_agent.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from clink.agents.base import CLIAgentError
|
||||
from clink.agents.claude import ClaudeAgent
|
||||
from clink.models import ResolvedCLIClient, ResolvedCLIRole
|
||||
|
||||
|
||||
class DummyProcess:
|
||||
def __init__(self, *, stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0):
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self.returncode = returncode
|
||||
self.stdin_data: bytes | None = None
|
||||
|
||||
async def communicate(self, input_data):
|
||||
self.stdin_data = input_data
|
||||
return self._stdout, self._stderr
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def claude_agent():
|
||||
prompt_path = Path("systemprompts/clink/default.txt").resolve()
|
||||
role = ResolvedCLIRole(name="default", prompt_path=prompt_path, role_args=[])
|
||||
client = ResolvedCLIClient(
|
||||
name="claude",
|
||||
executable=["claude"],
|
||||
internal_args=["--print", "--output-format", "json"],
|
||||
config_args=["--permission-mode", "acceptEdits"],
|
||||
env={},
|
||||
timeout_seconds=30,
|
||||
parser="claude_json",
|
||||
runner="claude",
|
||||
roles={"default": role},
|
||||
output_to_file=None,
|
||||
working_dir=None,
|
||||
)
|
||||
return ClaudeAgent(client), role
|
||||
|
||||
|
||||
async def _run_agent_with_process(monkeypatch, agent, role, process, *, system_prompt="System prompt"):
|
||||
async def fake_create_subprocess_exec(*_args, **_kwargs):
|
||||
return process
|
||||
|
||||
def fake_which(executable_name):
|
||||
return f"/usr/bin/{executable_name}"
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
monkeypatch.setattr(shutil, "which", fake_which)
|
||||
|
||||
return await agent.run(
|
||||
role=role,
|
||||
prompt="Respond with 42",
|
||||
system_prompt=system_prompt,
|
||||
files=[],
|
||||
images=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_injects_system_prompt(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
stdout_payload = json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"is_error": False,
|
||||
"result": "42",
|
||||
}
|
||||
).encode()
|
||||
process = DummyProcess(stdout=stdout_payload)
|
||||
|
||||
result = await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
|
||||
assert "--append-system-prompt" in result.sanitized_command
|
||||
idx = result.sanitized_command.index("--append-system-prompt")
|
||||
assert result.sanitized_command[idx + 1] == "System prompt"
|
||||
assert process.stdin_data.decode().startswith("Respond with 42")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_recovers_error_payload(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
stdout_payload = json.dumps(
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"is_error": True,
|
||||
"result": "API Error",
|
||||
}
|
||||
).encode()
|
||||
process = DummyProcess(stdout=stdout_payload, returncode=2)
|
||||
|
||||
result = await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
|
||||
assert result.returncode == 2
|
||||
assert result.parsed.content == "API Error"
|
||||
assert result.parsed.metadata["is_error"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_agent_propagates_unparseable_output(monkeypatch, claude_agent):
|
||||
agent, role = claude_agent
|
||||
process = DummyProcess(stdout=b"", returncode=1)
|
||||
|
||||
with pytest.raises(CLIAgentError):
|
||||
await _run_agent_with_process(monkeypatch, agent, role, process)
|
||||
45
tests/test_clink_claude_parser.py
Normal file
45
tests/test_clink_claude_parser.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Tests for the Claude CLI JSON parser."""
|
||||
|
||||
import pytest
|
||||
|
||||
from clink.parsers.base import ParserError
|
||||
from clink.parsers.claude import ClaudeJSONParser
|
||||
|
||||
|
||||
def _build_success_payload() -> str:
|
||||
return (
|
||||
'{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,'
|
||||
'"duration_api_ms":1200,"num_turns":1,"result":"42","session_id":"abc","total_cost_usd":0.12,'
|
||||
'"usage":{"input_tokens":10,"output_tokens":5},'
|
||||
'"modelUsage":{"claude-sonnet-4-5-20250929":{"inputTokens":10,"outputTokens":5}}}'
|
||||
)
|
||||
|
||||
|
||||
def test_claude_parser_extracts_result_and_metadata():
|
||||
parser = ClaudeJSONParser()
|
||||
stdout = _build_success_payload()
|
||||
|
||||
parsed = parser.parse(stdout=stdout, stderr="")
|
||||
|
||||
assert parsed.content == "42"
|
||||
assert parsed.metadata["model_used"] == "claude-sonnet-4-5-20250929"
|
||||
assert parsed.metadata["usage"]["output_tokens"] == 5
|
||||
assert parsed.metadata["is_error"] is False
|
||||
|
||||
|
||||
def test_claude_parser_falls_back_to_message():
|
||||
parser = ClaudeJSONParser()
|
||||
stdout = '{"type":"result","is_error":true,"message":"API error message"}'
|
||||
|
||||
parsed = parser.parse(stdout=stdout, stderr="warning")
|
||||
|
||||
assert parsed.content == "API error message"
|
||||
assert parsed.metadata["is_error"] is True
|
||||
assert parsed.metadata["stderr"] == "warning"
|
||||
|
||||
|
||||
def test_claude_parser_requires_output():
|
||||
parser = ClaudeJSONParser()
|
||||
|
||||
with pytest.raises(ParserError):
|
||||
parser.parse(stdout="", stderr="")
|
||||
@@ -40,3 +40,41 @@ async def test_clink_gemini_single_digit_sum():
|
||||
if status == "continuation_available":
|
||||
offer = payload.get("continuation_offer") or {}
|
||||
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_clink_claude_single_digit_sum():
|
||||
if shutil.which("claude") is None:
|
||||
pytest.skip("claude CLI is not installed or on PATH")
|
||||
|
||||
tool = CLinkTool()
|
||||
prompt = "Respond with a single digit equal to the sum of 2 + 2. Output only that digit."
|
||||
|
||||
results = await tool.execute(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"cli_name": "claude",
|
||||
"role": "default",
|
||||
"files": [],
|
||||
"images": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert results, "clink tool returned no outputs"
|
||||
payload = json.loads(results[0].text)
|
||||
status = payload["status"]
|
||||
|
||||
if status == "error":
|
||||
metadata = payload.get("metadata") or {}
|
||||
reason = payload.get("content") or metadata.get("message") or "Claude CLI reported an error"
|
||||
pytest.skip(f"Skipping Claude integration test: {reason}")
|
||||
|
||||
assert status in {"success", "continuation_available"}
|
||||
|
||||
content = payload.get("content", "").strip()
|
||||
assert content == "4"
|
||||
|
||||
if status == "continuation_available":
|
||||
offer = payload.get("continuation_offer") or {}
|
||||
assert offer.get("continuation_id"), "Expected continuation metadata when status indicates availability"
|
||||
|
||||
@@ -188,15 +188,29 @@ class CLinkTool(SimpleTool):
|
||||
|
||||
self._model_context = arguments.get("_model_context")
|
||||
|
||||
system_prompt_text = role_config.prompt_path.read_text(encoding="utf-8")
|
||||
include_system_prompt = not self._use_external_system_prompt(client_config)
|
||||
|
||||
try:
|
||||
prompt_text = await self._prepare_prompt_for_role(request, role_config)
|
||||
prompt_text = await self._prepare_prompt_for_role(
|
||||
request,
|
||||
role_config,
|
||||
system_prompt=system_prompt_text,
|
||||
include_system_prompt=include_system_prompt,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to prepare clink prompt")
|
||||
return [self._error_response(f"Failed to prepare prompt: {exc}")]
|
||||
|
||||
agent = create_agent(client_config)
|
||||
try:
|
||||
result = await agent.run(role=role_config, prompt=prompt_text, files=files, images=images)
|
||||
result = await agent.run(
|
||||
role=role_config,
|
||||
prompt=prompt_text,
|
||||
system_prompt=system_prompt_text if system_prompt_text.strip() else None,
|
||||
files=files,
|
||||
images=images,
|
||||
)
|
||||
except CLIAgentError as exc:
|
||||
metadata = self._build_error_metadata(client_config, exc)
|
||||
error_output = ToolOutput(
|
||||
@@ -249,11 +263,25 @@ class CLinkTool(SimpleTool):
|
||||
async def prepare_prompt(self, request) -> str:
|
||||
client_config = self._registry.get_client(request.cli_name)
|
||||
role_config = client_config.get_role(request.role)
|
||||
return await self._prepare_prompt_for_role(request, role_config)
|
||||
system_prompt_text = role_config.prompt_path.read_text(encoding="utf-8")
|
||||
include_system_prompt = not self._use_external_system_prompt(client_config)
|
||||
return await self._prepare_prompt_for_role(
|
||||
request,
|
||||
role_config,
|
||||
system_prompt=system_prompt_text,
|
||||
include_system_prompt=include_system_prompt,
|
||||
)
|
||||
|
||||
async def _prepare_prompt_for_role(self, request: CLinkRequest, role: ResolvedCLIRole) -> str:
|
||||
async def _prepare_prompt_for_role(
|
||||
self,
|
||||
request: CLinkRequest,
|
||||
role: ResolvedCLIRole,
|
||||
*,
|
||||
system_prompt: str,
|
||||
include_system_prompt: bool,
|
||||
) -> str:
|
||||
"""Load the role prompt and assemble the final user message."""
|
||||
self._active_system_prompt = role.prompt_path.read_text(encoding="utf-8")
|
||||
self._active_system_prompt = system_prompt
|
||||
try:
|
||||
user_content = self.handle_prompt_file_with_fallback(request).strip()
|
||||
guidance = self._agent_capabilities_guidance()
|
||||
@@ -261,7 +289,7 @@ class CLinkTool(SimpleTool):
|
||||
|
||||
sections: list[str] = []
|
||||
active_prompt = self.get_system_prompt().strip()
|
||||
if active_prompt:
|
||||
if include_system_prompt and active_prompt:
|
||||
sections.append(active_prompt)
|
||||
sections.append(guidance)
|
||||
sections.append("=== USER REQUEST ===\n" + user_content)
|
||||
@@ -272,6 +300,10 @@ class CLinkTool(SimpleTool):
|
||||
finally:
|
||||
self._active_system_prompt = ""
|
||||
|
||||
def _use_external_system_prompt(self, client: ResolvedCLIClient) -> bool:
|
||||
runner_name = (client.runner or client.name).lower()
|
||||
return runner_name == "claude"
|
||||
|
||||
def _build_success_metadata(
|
||||
self,
|
||||
client: ResolvedCLIClient,
|
||||
|
||||
Reference in New Issue
Block a user