pcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/__init__.py
ADDED
pcode/_job_supervisor.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Detached supervisor: owns one command's lifetime and publishes its exit status.
|
|
2
|
+
|
|
3
|
+
Runs in its own interpreter and its own session so the command survives agent
|
|
4
|
+
run teardown, event-loop shutdown, and pcode itself. It writes `status.json`
|
|
5
|
+
atomically, which is the only thing the parent polls: a supervisor killed
|
|
6
|
+
between publications leaves the last consistent snapshot rather than a partial
|
|
7
|
+
file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None: # pragma: no cover -- exercised through a real subprocess.
|
|
22
|
+
directory = Path(sys.argv[1])
|
|
23
|
+
command = sys.argv[2]
|
|
24
|
+
status = directory / "status.json"
|
|
25
|
+
pending = directory / "status.tmp"
|
|
26
|
+
|
|
27
|
+
def publish(*, pid: int, exit_code: int | None, started_at: float) -> None:
|
|
28
|
+
pending.write_text(
|
|
29
|
+
json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"pid": pid,
|
|
32
|
+
"exit_code": exit_code,
|
|
33
|
+
"started_at": started_at,
|
|
34
|
+
"ended_at": None if exit_code is None else time.time(),
|
|
35
|
+
}
|
|
36
|
+
),
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
)
|
|
39
|
+
pending.replace(status)
|
|
40
|
+
|
|
41
|
+
with (directory / "output.log").open("ab", buffering=0) as output:
|
|
42
|
+
started_at = time.time()
|
|
43
|
+
process = subprocess.Popen(
|
|
44
|
+
command,
|
|
45
|
+
shell=True,
|
|
46
|
+
stdin=subprocess.DEVNULL,
|
|
47
|
+
stdout=output,
|
|
48
|
+
stderr=output,
|
|
49
|
+
)
|
|
50
|
+
publish(pid=process.pid, exit_code=None, started_at=started_at)
|
|
51
|
+
exit_code = process.wait()
|
|
52
|
+
publish(pid=process.pid, exit_code=exit_code, started_at=started_at)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__": # pragma: no cover
|
|
56
|
+
# The parent starts a new session for us. Ignore terminal hangups without
|
|
57
|
+
# changing how the child command itself handles termination. A stop is
|
|
58
|
+
# SIGTERM to the whole group: survive it here so the command's own exit
|
|
59
|
+
# under it still gets published. A Python handler rather than SIG_IGN,
|
|
60
|
+
# because an ignored disposition is inherited across exec and would make
|
|
61
|
+
# the command itself immune to the stop; a handler resets to default.
|
|
62
|
+
if os.name != "nt":
|
|
63
|
+
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
64
|
+
signal.signal(signal.SIGTERM, lambda *_: None)
|
|
65
|
+
main()
|
pcode/agent.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""Agent construction is independent of the terminal and runtime adapter."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from copy import copy
|
|
10
|
+
from dataclasses import dataclass, fields, replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic_ai import Agent
|
|
14
|
+
from pydantic_ai.capabilities import (
|
|
15
|
+
AbstractCapability,
|
|
16
|
+
Capability,
|
|
17
|
+
CombinedCapability,
|
|
18
|
+
LocalWorkspace,
|
|
19
|
+
)
|
|
20
|
+
from pydantic_ai.models import Model
|
|
21
|
+
from pydantic_ai.models.openai_codex import OpenAICodexModel
|
|
22
|
+
from pydantic_ai.profiles.openai import OpenAIModelProfile
|
|
23
|
+
from pydantic_ai.providers.openai_codex import OpenAICodexProvider
|
|
24
|
+
from pydantic_ai.toolsets import CombinedToolset
|
|
25
|
+
from pydantic_ai.usage import UsageLimits
|
|
26
|
+
from pydantic_ai_harness.coder import Coder
|
|
27
|
+
from pydantic_ai_harness.compaction import ClearToolResults, WarnNearLimits
|
|
28
|
+
from pydantic_ai_harness.filesystem import FileSystem
|
|
29
|
+
from pydantic_ai_harness.repo_context import RepoContext
|
|
30
|
+
from pydantic_ai_harness.shell import LLM_API_KEY_ENV_PATTERNS, Shell
|
|
31
|
+
from pydantic_ai_harness.subagents import ModelOption, SubAgent
|
|
32
|
+
from pydantic_ai_harness.tool_output_limits import ToolOutputLimits
|
|
33
|
+
|
|
34
|
+
from pcode import remote_profile
|
|
35
|
+
from pcode.cache_settings import ProviderCacheSettings, model_settings
|
|
36
|
+
from pcode.cache_warnings import CacheBustReporting
|
|
37
|
+
from pcode.claude_sdk import ClaudeWorkspace
|
|
38
|
+
from pcode.code_mode import create_code_mode
|
|
39
|
+
from pcode.delegation import DelegationReporting, stream_child_activity
|
|
40
|
+
from pcode.ext import EXTENSION_GUIDE, ExtensionCapabilities
|
|
41
|
+
from pcode.filesystem import DisplayFileSystem
|
|
42
|
+
from pcode.isolated_delegation import WorkspaceSubAgents
|
|
43
|
+
from pcode.job_notices import JobNotices
|
|
44
|
+
from pcode.jobs import isolated_registry
|
|
45
|
+
from pcode.llm_proxy import ProxiedCodexProvider
|
|
46
|
+
from pcode.mcp import configured_servers
|
|
47
|
+
from pcode.mcp_notice import MCPServers
|
|
48
|
+
from pcode.meridian import MeridianSessionIdentity
|
|
49
|
+
from pcode.meridian_reminders import MeridianLimitWarnings
|
|
50
|
+
from pcode.output_limits import ModelOutputLimits
|
|
51
|
+
from pcode.planning import IdentifiedPlanning
|
|
52
|
+
from pcode.preferences import SETTINGS, load_preferences, subagent_models
|
|
53
|
+
from pcode.repo_context import create_repo_context
|
|
54
|
+
from pcode.shell_tools import JobShell
|
|
55
|
+
from pcode.strict_tools import create_strict_tools
|
|
56
|
+
from pcode.tool_output_limits import create_tool_output_limits
|
|
57
|
+
from pcode.workspace import WorkspaceGuard
|
|
58
|
+
|
|
59
|
+
# Harness gives a child its own usage counter only when its `SubAgent` carries
|
|
60
|
+
# `usage_limits`; without one the child shares the parent's counter and silently
|
|
61
|
+
# gets the library's 50-request default, which a busy session has already spent.
|
|
62
|
+
# The limits set no cap: like the parent turn, which is uncapped too, a child's
|
|
63
|
+
# activity is on screen and Ctrl+C stops it, while a fixed budget discarded a
|
|
64
|
+
# working child's whole result. `pcode.ext.subagent` applies this to extension
|
|
65
|
+
# delegates too.
|
|
66
|
+
SUBAGENT_USAGE_LIMITS = UsageLimits(request_limit=None)
|
|
67
|
+
|
|
68
|
+
# Coder's default prompt without "finish long-running work before responding",
|
|
69
|
+
# which kept the model waiting on jobs instead of answering steering. Job
|
|
70
|
+
# mechanics are in the shell tool descriptions; don't add workflow rules here.
|
|
71
|
+
CODER_INSTRUCTIONS = """\
|
|
72
|
+
You are a software engineering agent. Use tools to investigate, implement, and
|
|
73
|
+
verify the requested work. Read existing code and follow repository instructions
|
|
74
|
+
and conventions. Prefer focused changes that fix causes, not symptoms.
|
|
75
|
+
|
|
76
|
+
Apply DRY, YAGNI, SOLID, and the Zen of Python pragmatically: simple, explicit,
|
|
77
|
+
cohesive code beats abstractions without a present need.
|
|
78
|
+
|
|
79
|
+
Work autonomously until complete. Ask only for missing requirements, credentials,
|
|
80
|
+
consequential ambiguity, or approval for irreversible actions. Use reasonable
|
|
81
|
+
defaults for minor ambiguities. Run focused tests and appropriate lint/type checks;
|
|
82
|
+
report what you actually verified, assumptions, and remaining limitations.
|
|
83
|
+
|
|
84
|
+
Servers may remain running once readiness is verified; shut them down when no
|
|
85
|
+
longer needed.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
AGENT_INSTRUCTIONS = (
|
|
89
|
+
"Responses are displayed in a terminal with Markdown rendering "
|
|
90
|
+
"and syntax highlighting. "
|
|
91
|
+
"Use fenced code blocks with a language tag for multiline code or shell examples, "
|
|
92
|
+
"and inline backticks for identifiers and short commands. Close all code fences. "
|
|
93
|
+
"Write ordinary prose outside code blocks. "
|
|
94
|
+
"Full URLs are clickable in the terminal, so use them liberally and "
|
|
95
|
+
"prefer them over bare references like issue numbers or page titles. "
|
|
96
|
+
# /copy offers quotes as their own choice, since the rendered rail and wrap
|
|
97
|
+
# make them hard to select by hand.
|
|
98
|
+
"Put text meant for the user to paste elsewhere in a > blockquote. "
|
|
99
|
+
# GPT-6 tends to edit through shell commands, bypassing captured edit diffs.
|
|
100
|
+
"Prefer edit_file and write_file for file changes over shell tools. "
|
|
101
|
+
# The model is the intended extension author, so it needs to know the
|
|
102
|
+
# mechanism exists without the reference text sitting in every prompt.
|
|
103
|
+
"pcode itself is extensible with small Python files (new slash commands, "
|
|
104
|
+
"tools, guardrails on tool calls, extra instructions). When asked to change "
|
|
105
|
+
f"how pcode behaves, first read {EXTENSION_GUIDE} and follow it."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_worker_toolsets: ContextVar[Sequence] = ContextVar("worker_toolsets", default=())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class WorkerRuntimeTools(CombinedToolset):
|
|
114
|
+
"""Stands in for the delegating turn's runtime toolsets in each worker run.
|
|
115
|
+
|
|
116
|
+
Resolved once per run rather than by a per-step toolset function, which
|
|
117
|
+
would exit and re-enter them at every step, and through their own
|
|
118
|
+
`for_run`, so a live MCP toolset gets a fresh instance for this worker and
|
|
119
|
+
still follows /mcp changes at each of its steps.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
toolsets: Sequence = ()
|
|
123
|
+
|
|
124
|
+
async def for_run(self, ctx):
|
|
125
|
+
return await CombinedToolset(list(_worker_toolsets.get())).for_run(ctx)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def worker_toolsets(toolsets: Sequence):
|
|
130
|
+
"""Give the built-in worker the same enabled runtime toolsets as its parent.
|
|
131
|
+
|
|
132
|
+
Harness's inherit_tools covers constructor toolsets, not per-run MCP tools,
|
|
133
|
+
and would also broaden specialized extension delegates. Context-local agent
|
|
134
|
+
bindings keep this scoped to the worker and to this turn, including errors
|
|
135
|
+
and cancellation; no stale MCP connection is retained after disable/reload.
|
|
136
|
+
"""
|
|
137
|
+
token = _worker_toolsets.set(tuple(toolsets))
|
|
138
|
+
try:
|
|
139
|
+
yield
|
|
140
|
+
finally:
|
|
141
|
+
_worker_toolsets.reset(token)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tool_retries() -> dict[str, int]:
|
|
145
|
+
"""The correction budget for tool-argument validation and `ModelRetry`.
|
|
146
|
+
|
|
147
|
+
Pydantic AI defaults both tool and output retries to 1, so a second
|
|
148
|
+
malformed call to a tool ends the whole turn with `UnexpectedModelBehavior`.
|
|
149
|
+
A nested argument like `edit_file`'s `replacements` array is easy to mangle
|
|
150
|
+
twice in a row, and a correction costs one round trip where the failure
|
|
151
|
+
costs the turn. Output retries keep the stricter default: a model that
|
|
152
|
+
cannot produce the final output shape twice is not going to converge.
|
|
153
|
+
"""
|
|
154
|
+
configured = load_preferences().get("tool_retries", SETTINGS["tool_retries"].default)
|
|
155
|
+
return {"tools": int(configured)}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def has_mcp_servers() -> bool:
|
|
159
|
+
"""Whether mcp.json configures any server, deciding once per agent.
|
|
160
|
+
|
|
161
|
+
A broken file counts as none: nothing can be enabled from it, and `/mcp`
|
|
162
|
+
reports the error when it is used.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
return bool(configured_servers())
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _init_fields(capability) -> dict:
|
|
171
|
+
"""A dataclass capability's constructor arguments, to rebuild it as a subclass."""
|
|
172
|
+
return {f.name: getattr(capability, f.name) for f in fields(capability) if f.init}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _adapt(capability, workspace: Path, output_limits):
|
|
176
|
+
"""Map one of Coder's capabilities to pcode's replacement, or keep it.
|
|
177
|
+
|
|
178
|
+
Ids must be set at construction (`replace(..., id=...)` or the factory):
|
|
179
|
+
a `Capability` binds its instructions to its id in `__init__`, so a later
|
|
180
|
+
`capability.id = ...` is silently ignored by /status attribution. Do not
|
|
181
|
+
blanket-rename everything either: `replace()`-copied children compare
|
|
182
|
+
fields with their parent and a renamed parent breaks that match.
|
|
183
|
+
"""
|
|
184
|
+
match capability:
|
|
185
|
+
# Coder's sole plain Capability holds its base instructions (and a
|
|
186
|
+
# project line pcode's file-tool instructions already cover). Exact type:
|
|
187
|
+
# every capability class below would also match `Capability()`.
|
|
188
|
+
case Capability() if type(capability) is Capability:
|
|
189
|
+
return Capability(instructions=CODER_INSTRUCTIONS)
|
|
190
|
+
case RepoContext():
|
|
191
|
+
return create_repo_context(workspace)
|
|
192
|
+
# Named so /status can attribute its prompt; ids never reach the model.
|
|
193
|
+
# No per-toolset retry budget: the `tool_retries` preference governs.
|
|
194
|
+
case FileSystem():
|
|
195
|
+
return replace(
|
|
196
|
+
DisplayFileSystem.from_filesystem(capability), id="file_tools", max_retries=None
|
|
197
|
+
)
|
|
198
|
+
# Replace Coder's 64k truncation, so it cannot cut data before spilling.
|
|
199
|
+
case ToolOutputLimits():
|
|
200
|
+
return output_limits
|
|
201
|
+
case WarnNearLimits():
|
|
202
|
+
return MeridianLimitWarnings(**_init_fields(capability))
|
|
203
|
+
case Shell():
|
|
204
|
+
return _job_shell(capability, workspace)
|
|
205
|
+
case _:
|
|
206
|
+
return capability
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _job_shell(shell: Shell, workspace: Path) -> JobShell:
|
|
210
|
+
"""Coder's persistent shell with commands run as named jobs.
|
|
211
|
+
|
|
212
|
+
Same execution model, but commands become named jobs the session can wait
|
|
213
|
+
on, report and stop. See `pcode.shell_tools`. Copied field by field
|
|
214
|
+
(including `id`) because a capability binds its instructions to its id in
|
|
215
|
+
`__init__`.
|
|
216
|
+
"""
|
|
217
|
+
values = _init_fields(shell)
|
|
218
|
+
# direnv writes its status banner to stderr on every cd into a managed
|
|
219
|
+
# directory, which pollutes command output the agent parses (e.g.
|
|
220
|
+
# `... | jq`). An empty log format silences it.
|
|
221
|
+
values["env"] = {**(shell.env or os.environ), "DIRENV_LOG_FORMAT": ""}
|
|
222
|
+
# Commands inherit the whole host environment (see `JobShell`), so keep
|
|
223
|
+
# provider credentials out of it, as Coder did before it left the
|
|
224
|
+
# environment to the workspace.
|
|
225
|
+
values["denied_env_patterns"] = [*values["denied_env_patterns"], *LLM_API_KEY_ENV_PATTERNS]
|
|
226
|
+
return JobShell(**values, workdir=workspace)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _ensure_bundled_rg() -> None:
|
|
230
|
+
"""uv tool entry points do not activate their environment's bin directory.
|
|
231
|
+
|
|
232
|
+
Append it only when rg is missing, preserving the user's command precedence.
|
|
233
|
+
"""
|
|
234
|
+
bundled_bin = Path(sys.executable).parent
|
|
235
|
+
if shutil.which("rg") is None and (bundled_bin / "rg").is_file():
|
|
236
|
+
os.environ["PATH"] = os.pathsep.join(
|
|
237
|
+
part for part in (os.environ.get("PATH", ""), str(bundled_bin)) if part
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _cache_notices() -> CacheBustReporting | None:
|
|
242
|
+
preferences = load_preferences()
|
|
243
|
+
if preferences.get("cache_notices", SETTINGS["cache_notices"].default) != "on":
|
|
244
|
+
return None
|
|
245
|
+
return CacheBustReporting(
|
|
246
|
+
dump_fingerprints=preferences.get("debug", SETTINGS["debug"].default) == "on"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TurnLimits(AbstractCapability):
|
|
251
|
+
"""Charges every model request and tool call to a remote host's turn budget.
|
|
252
|
+
|
|
253
|
+
In the agent's capabilities and in delegation's shared ones, so a
|
|
254
|
+
sub-agent's work counts against the turn that delegated it. A sub-agent's
|
|
255
|
+
failure comes back to the parent as a retryable result; the parent's next
|
|
256
|
+
request then fails too, as the budget stays spent.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
async def before_model_request(self, ctx, request_context):
|
|
260
|
+
remote_profile.BUDGET.charge_request()
|
|
261
|
+
return request_context
|
|
262
|
+
|
|
263
|
+
async def before_tool_execute(self, ctx, *, call, tool_def, args):
|
|
264
|
+
remote_profile.BUDGET.charge_tool_call()
|
|
265
|
+
return args
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def turn_limits() -> list[TurnLimits]:
|
|
269
|
+
"""The limits when a remote profile is active (`pcode.remote_profile`), else none."""
|
|
270
|
+
return [TurnLimits()] if remote_profile.active() is not None else []
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _worker_capabilities(capabilities: Sequence) -> list:
|
|
274
|
+
"""The parent's capabilities, copied for the built-in worker.
|
|
275
|
+
|
|
276
|
+
Compose the worker from the same capabilities rather than maintaining a
|
|
277
|
+
second tool/policy list. Per-run capability state is still managed upstream.
|
|
278
|
+
The shared types are supplied by SubAgents.shared_capabilities instead (also
|
|
279
|
+
for extension delegates); delegation itself is intentionally parent-only.
|
|
280
|
+
"""
|
|
281
|
+
shared_types = (
|
|
282
|
+
ToolOutputLimits,
|
|
283
|
+
MeridianSessionIdentity,
|
|
284
|
+
ModelOutputLimits,
|
|
285
|
+
CacheBustReporting,
|
|
286
|
+
TurnLimits,
|
|
287
|
+
)
|
|
288
|
+
return [
|
|
289
|
+
copy(capability)
|
|
290
|
+
for capability in capabilities
|
|
291
|
+
if not isinstance(capability, (*shared_types, ClearToolResults, DelegationReporting))
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _delegation(
|
|
296
|
+
workspace: Path,
|
|
297
|
+
worker: Agent,
|
|
298
|
+
subagents: Sequence,
|
|
299
|
+
extensions: Sequence,
|
|
300
|
+
output_limits,
|
|
301
|
+
cache_notices: CacheBustReporting | None,
|
|
302
|
+
) -> WorkspaceSubAgents:
|
|
303
|
+
"""The `delegate_task` tool: the worker beside extension sub-agents."""
|
|
304
|
+
|
|
305
|
+
@asynccontextmanager
|
|
306
|
+
async def isolated_worker(child_workspace: Path):
|
|
307
|
+
if extensions and not isinstance(extensions, ExtensionCapabilities):
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"Isolated workers require rebindable extensions from load_extensions(); "
|
|
310
|
+
"use workspace_mode='shared' for directly supplied capabilities."
|
|
311
|
+
)
|
|
312
|
+
rebound = (
|
|
313
|
+
extensions
|
|
314
|
+
if isinstance(extensions, ExtensionCapabilities)
|
|
315
|
+
else ExtensionCapabilities([])
|
|
316
|
+
)
|
|
317
|
+
with isolated_registry() as jobs:
|
|
318
|
+
async with rebound.for_workspace(child_workspace) as child_extensions:
|
|
319
|
+
child_coder = create_coder(child_workspace, delegation=False)
|
|
320
|
+
child_coder.capabilities.append(JobNotices(jobs))
|
|
321
|
+
yield _create_worker(child_coder.capabilities, child_extensions, isolated=True)
|
|
322
|
+
|
|
323
|
+
return WorkspaceSubAgents(
|
|
324
|
+
workspace=workspace,
|
|
325
|
+
worker_factory=isolated_worker,
|
|
326
|
+
agents=[
|
|
327
|
+
SubAgent(worker, usage_limits=SUBAGENT_USAGE_LIMITS),
|
|
328
|
+
*subagents,
|
|
329
|
+
],
|
|
330
|
+
agent_folders=None,
|
|
331
|
+
# Unavailable names are left out here and reported by /subagents.
|
|
332
|
+
models=subagent_menu(subagent_models())[0],
|
|
333
|
+
event_stream_handler=stream_child_activity,
|
|
334
|
+
# One child's crash (a dropped connection past its retries, a bug in a
|
|
335
|
+
# tool) comes back to the parent as a retryable failure. Propagating it
|
|
336
|
+
# fails the parent's turn, which cancels every sibling delegation.
|
|
337
|
+
contain_errors=True,
|
|
338
|
+
shared_capabilities=[
|
|
339
|
+
# Extension sub-agents have no coder of their own to set it.
|
|
340
|
+
ClaudeWorkspace(workspace, fallback=True),
|
|
341
|
+
MeridianSessionIdentity(),
|
|
342
|
+
ModelOutputLimits(),
|
|
343
|
+
*([replace(cache_notices)] if cache_notices else []),
|
|
344
|
+
ProviderCacheSettings(),
|
|
345
|
+
replace(output_limits),
|
|
346
|
+
# A remote host's turn budget counts its sub-agents' work too.
|
|
347
|
+
*turn_limits(),
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def create_coder(
|
|
353
|
+
workspace: Path, subagents: Sequence = (), extensions: Sequence = (), *, delegation: bool = True
|
|
354
|
+
) -> CombinedCapability:
|
|
355
|
+
"""Compose Harness's Coder with pcode's repository context and planning.
|
|
356
|
+
|
|
357
|
+
`subagents` are extension-contributed Harness `SubAgent` entries, listed
|
|
358
|
+
beside the worker under the one `delegate_task` tool.
|
|
359
|
+
"""
|
|
360
|
+
workspace = workspace.resolve()
|
|
361
|
+
_ensure_bundled_rg()
|
|
362
|
+
# Delegation is pcode's own `WorkspaceSubAgents`, added below.
|
|
363
|
+
coder = Coder(sub_agents=False)
|
|
364
|
+
output_limits = create_tool_output_limits()
|
|
365
|
+
# Keep Coder's tool selection, including its persistent shell (as a
|
|
366
|
+
# `JobShell`). File display and repository discovery remain local adapters;
|
|
367
|
+
# planning is now opt-in.
|
|
368
|
+
capabilities = [_adapt(c, workspace, output_limits) for c in coder.capabilities]
|
|
369
|
+
capabilities = [
|
|
370
|
+
# Ahead of the other capabilities in the list, so a tool call in a
|
|
371
|
+
# deleted workspace stops before anything tries to read or write in it.
|
|
372
|
+
WorkspaceGuard(workspace),
|
|
373
|
+
# Harness capabilities read and write through `ctx.workspace`. Commands
|
|
374
|
+
# bypass it (see `JobShell`), so it needs none of the host's environment.
|
|
375
|
+
LocalWorkspace(workspace),
|
|
376
|
+
*capabilities,
|
|
377
|
+
# Isolated workers build their own coder, so each CLI runs in its checkout.
|
|
378
|
+
ClaudeWorkspace(workspace),
|
|
379
|
+
IdentifiedPlanning(),
|
|
380
|
+
DelegationReporting(),
|
|
381
|
+
MeridianSessionIdentity(),
|
|
382
|
+
ModelOutputLimits(),
|
|
383
|
+
*turn_limits(),
|
|
384
|
+
]
|
|
385
|
+
# Ahead of the worker copy below, so a delegate edits under the same schema.
|
|
386
|
+
if strict_tools := create_strict_tools():
|
|
387
|
+
capabilities.append(strict_tools)
|
|
388
|
+
# Ahead of the worker copy below, so a delegate that inherits MCP tools also
|
|
389
|
+
# learns which servers they come from.
|
|
390
|
+
capabilities.append(MCPServers(instruct=has_mcp_servers()))
|
|
391
|
+
if cache_notices := _cache_notices():
|
|
392
|
+
capabilities.append(cache_notices)
|
|
393
|
+
|
|
394
|
+
worker_capabilities = _worker_capabilities(capabilities)
|
|
395
|
+
if code_mode := create_code_mode():
|
|
396
|
+
capabilities.append(code_mode)
|
|
397
|
+
worker_capabilities.append(copy(code_mode))
|
|
398
|
+
if not delegation:
|
|
399
|
+
return CombinedCapability(worker_capabilities)
|
|
400
|
+
|
|
401
|
+
worker = _create_worker(worker_capabilities, extensions)
|
|
402
|
+
capabilities.append(
|
|
403
|
+
_delegation(workspace, worker, subagents, extensions, output_limits, cache_notices)
|
|
404
|
+
)
|
|
405
|
+
# Summarize evidence before discarding it; pcode owns compaction.
|
|
406
|
+
return CombinedCapability([c for c in capabilities if not isinstance(c, ClearToolResults)])
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _create_worker(
|
|
410
|
+
capabilities: Sequence, extensions: Sequence, *, isolated: bool = False
|
|
411
|
+
) -> Agent:
|
|
412
|
+
return Agent(
|
|
413
|
+
name="worker",
|
|
414
|
+
retries=tool_retries(),
|
|
415
|
+
description=(
|
|
416
|
+
"Complete a self-contained task using the main agent's tools and permissions, "
|
|
417
|
+
"including file edits, shell commands, tests, web research, and enabled MCP tools"
|
|
418
|
+
),
|
|
419
|
+
instructions=AGENT_INSTRUCTIONS
|
|
420
|
+
+ (
|
|
421
|
+
" You are a general-purpose worker. Complete only the delegated task and report "
|
|
422
|
+
"your changes, verification, and remaining limitations. You inherit the main "
|
|
423
|
+
"agent's instructions, tools, and permission checks, but not its conversation. "
|
|
424
|
+
"Your plan is independent. You cannot delegate further or manage other task worktrees. "
|
|
425
|
+
"Stop jobs you no longer need with stop_job. "
|
|
426
|
+
)
|
|
427
|
+
+ (
|
|
428
|
+
"You have an isolated checkout. Commit completed changes here; do not push or merge "
|
|
429
|
+
"into any other checkout. Parent integration and cleanup happen after you return."
|
|
430
|
+
if isolated
|
|
431
|
+
else "You share the parent's workspace: coordinate edits with the parent."
|
|
432
|
+
),
|
|
433
|
+
capabilities=[*capabilities, *extensions],
|
|
434
|
+
toolsets=[WorkerRuntimeTools()],
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def codex_model(model: str) -> OpenAICodexModel:
|
|
439
|
+
"""Build a Codex model on pcode's stored login, else the CLI's `auth.json`.
|
|
440
|
+
|
|
441
|
+
Without a pcode store the provider reads the CLI file at construction time
|
|
442
|
+
and keeps refreshed tokens in memory only.
|
|
443
|
+
"""
|
|
444
|
+
from pcode.codex_login import credential_source
|
|
445
|
+
|
|
446
|
+
proxy = os.environ.get("PCODE_LLM_PROXY", "").strip()
|
|
447
|
+
source = credential_source()
|
|
448
|
+
if proxy:
|
|
449
|
+
provider = ProxiedCodexProvider(proxy, credential_source=source)
|
|
450
|
+
elif source is not None:
|
|
451
|
+
provider = OpenAICodexProvider(credential_source=source)
|
|
452
|
+
else:
|
|
453
|
+
provider = None
|
|
454
|
+
# Subscription endpoints reject the explicit cache markers that Harness
|
|
455
|
+
# Planning adds after write_plan. Keep the native provider/auth/model name;
|
|
456
|
+
# override only this advertised capability (verified against AI 2.43.0).
|
|
457
|
+
# The tool-search deferral and addition modes come from the provider's
|
|
458
|
+
# profile, which layers `OpenAIProvider.model_profile` under the Codex
|
|
459
|
+
# dialect since AI 2.49.0 (pydantic/pydantic-ai#8693).
|
|
460
|
+
return OpenAICodexModel(
|
|
461
|
+
model.removeprefix("openai-codex:"),
|
|
462
|
+
profile=OpenAIModelProfile(openai_supports_prompt_cache_breakpoints=False),
|
|
463
|
+
**({"provider": provider} if provider is not None else {}),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def resolve_model(model: str) -> Model | str:
|
|
468
|
+
"""The model object for a name, on pcode's own logins where it manages them.
|
|
469
|
+
|
|
470
|
+
Names pcode has no special handling for come back unchanged for Pydantic
|
|
471
|
+
AI's inference. So does an Anthropic name when API-key auth has no key yet,
|
|
472
|
+
which lets the terminal open and reach /login.
|
|
473
|
+
"""
|
|
474
|
+
if model.startswith("openai-codex:"):
|
|
475
|
+
return codex_model(model)
|
|
476
|
+
if model.startswith("meridian:"):
|
|
477
|
+
from pcode.models import LEGACY_ANTHROPIC_AUTH
|
|
478
|
+
|
|
479
|
+
if not LEGACY_ANTHROPIC_AUTH:
|
|
480
|
+
name = model.removeprefix("meridian:")
|
|
481
|
+
raise ValueError(f"Meridian is turned off; switch to claude:{name} with /model.")
|
|
482
|
+
from pcode.meridian import meridian_model
|
|
483
|
+
|
|
484
|
+
return meridian_model(model)
|
|
485
|
+
if model.startswith("claude:"):
|
|
486
|
+
from pcode.claude_sdk import claude_model
|
|
487
|
+
|
|
488
|
+
return claude_model(model)
|
|
489
|
+
if not model.startswith("anthropic:"):
|
|
490
|
+
return model
|
|
491
|
+
from pcode.anthropic_oauth import anthropic_auth_source
|
|
492
|
+
from pcode.auth import anthropic_model
|
|
493
|
+
|
|
494
|
+
auth_source = anthropic_auth_source()
|
|
495
|
+
if auth_source == "oauth":
|
|
496
|
+
from pcode.anthropic_oauth import AnthropicOAuthModel
|
|
497
|
+
|
|
498
|
+
return AnthropicOAuthModel(model)
|
|
499
|
+
if auth_source == "api-key":
|
|
500
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
501
|
+
return anthropic_model(model, key) if key else model
|
|
502
|
+
raise ValueError("PCODE_ANTHROPIC_AUTH must be api-key or oauth.")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass(frozen=True)
|
|
506
|
+
class SideModel:
|
|
507
|
+
"""A model a single run uses instead of the agent's, with its own settings."""
|
|
508
|
+
|
|
509
|
+
name: str
|
|
510
|
+
model: Model
|
|
511
|
+
settings: dict | None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def side_model(name: str, effort: str = "") -> SideModel:
|
|
515
|
+
"""Resolve `name` now, failing with a clear message rather than on first request.
|
|
516
|
+
|
|
517
|
+
The settings are the ones the model would get as the conversation's model:
|
|
518
|
+
its defaults plus its own saved /effort, or `effort` when one was asked
|
|
519
|
+
for. None of the conversation model's settings carry over, since they
|
|
520
|
+
belong to another model or provider.
|
|
521
|
+
"""
|
|
522
|
+
from pydantic_ai.exceptions import UserError
|
|
523
|
+
from pydantic_ai.models import infer_model
|
|
524
|
+
|
|
525
|
+
from pcode.preferences import effort_for
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
resolved = resolve_model(name)
|
|
529
|
+
if isinstance(resolved, str):
|
|
530
|
+
if name.startswith("anthropic:"):
|
|
531
|
+
from pcode.models import anthropic_credential_hint
|
|
532
|
+
|
|
533
|
+
raise ValueError(f"no Anthropic credentials. {anthropic_credential_hint()}")
|
|
534
|
+
resolved = infer_model(resolved)
|
|
535
|
+
except (UserError, ValueError, ImportError) as error:
|
|
536
|
+
raise ValueError(f"Cannot use {name}: {error}") from error
|
|
537
|
+
return SideModel(
|
|
538
|
+
name,
|
|
539
|
+
resolved,
|
|
540
|
+
with_effort(name, resolved, model_settings(name), effort or effort_for(name)),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def subagent_menu(names: Sequence[str]) -> tuple[dict[str, ModelOption], list[str]]:
|
|
545
|
+
"""The `delegate_task` model menu for `names`, and why any name was left out.
|
|
546
|
+
|
|
547
|
+
Each option is resolved as /btw resolves another model: on pcode's own
|
|
548
|
+
logins, with that model's defaults and saved /effort. Omitting `model` in a
|
|
549
|
+
delegation still runs the sub-agent on the session's model.
|
|
550
|
+
"""
|
|
551
|
+
menu: dict[str, ModelOption] = {}
|
|
552
|
+
problems: list[str] = []
|
|
553
|
+
for name in names:
|
|
554
|
+
try:
|
|
555
|
+
chosen = side_model(name)
|
|
556
|
+
except ValueError as error:
|
|
557
|
+
problems.append(str(error))
|
|
558
|
+
continue
|
|
559
|
+
menu[name] = ModelOption(chosen.model, settings=chosen.settings)
|
|
560
|
+
return menu, problems
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def with_effort(name: str, model, settings: dict | None, effort: str | None) -> dict | None:
|
|
564
|
+
"""`settings` with `effort` applied for `name` the way /effort applies it.
|
|
565
|
+
|
|
566
|
+
`settings` itself is never mutated: it may be captured by a run in flight.
|
|
567
|
+
"""
|
|
568
|
+
from types import SimpleNamespace
|
|
569
|
+
|
|
570
|
+
from pcode.preferences import apply_effort
|
|
571
|
+
|
|
572
|
+
holder = SimpleNamespace(model=model, model_settings=settings)
|
|
573
|
+
apply_effort(holder, name, effort)
|
|
574
|
+
return holder.model_settings
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def create_agent(
|
|
578
|
+
model: str, workspace: Path, extensions: Sequence = (), subagents: Sequence = ()
|
|
579
|
+
) -> Agent:
|
|
580
|
+
"""Build the terminal's agent; `extensions` and `subagents` come from `pcode.ext`."""
|
|
581
|
+
resolved = resolve_model(model)
|
|
582
|
+
# Allow the terminal to open so /login is reachable without credentials.
|
|
583
|
+
defer_model_check = model.startswith("anthropic:") and isinstance(resolved, str)
|
|
584
|
+
return Agent(
|
|
585
|
+
resolved,
|
|
586
|
+
defer_model_check=defer_model_check,
|
|
587
|
+
model_settings=model_settings(model),
|
|
588
|
+
name="pcode",
|
|
589
|
+
retries=tool_retries(),
|
|
590
|
+
instructions=AGENT_INSTRUCTIONS,
|
|
591
|
+
capabilities=[create_coder(workspace, subagents, extensions), *extensions],
|
|
592
|
+
)
|