codicent-cli 0.8.6__tar.gz → 0.9.0__tar.gz
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.
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/PKG-INFO +11 -1
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/README.md +10 -0
- codicent_cli-0.9.0/agent.py +431 -0
- codicent_cli-0.9.0/agent_policy.py +78 -0
- codicent_cli-0.9.0/agent_protocol.py +76 -0
- codicent_cli-0.9.0/agent_session.py +73 -0
- codicent_cli-0.9.0/agent_tools.py +430 -0
- codicent_cli-0.9.0/agent_workspace.py +113 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/app.py +1462 -1412
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/codicent_cli.egg-info/PKG-INFO +11 -1
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/codicent_cli.egg-info/SOURCES.txt +6 -0
- codicent_cli-0.9.0/codicent_cli.egg-info/top_level.txt +12 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/setup.py +61 -57
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/terminal.py +344 -252
- codicent_cli-0.8.6/codicent_cli.egg-info/top_level.txt +0 -6
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/LICENSE +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/auth.py +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/codicent_cli.egg-info/dependency_links.txt +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/codicent_cli.egg-info/entry_points.txt +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/codicent_cli.egg-info/requires.txt +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/setup.cfg +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/templates/__init__.py +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/templates/default.json +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/templates/document-processing.json +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/templates/koi-studio.json +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/tui.py +0 -0
- {codicent_cli-0.8.6 → codicent_cli-0.9.0}/wrap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codicent-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Command-line interface for the Codicent API
|
|
5
5
|
Home-page: https://github.com/izaxon/codicent-cli
|
|
6
6
|
Author: Johan Isaksson
|
|
@@ -101,6 +101,16 @@ pip install git+https://github.com/izaxon/codicent-cli.git@v0.4.8
|
|
|
101
101
|
|
|
102
102
|
## Usage
|
|
103
103
|
|
|
104
|
+
### Coding agent sandbox
|
|
105
|
+
|
|
106
|
+
`codicent agent exec` defaults to `--sandbox workspace-write`, which confines
|
|
107
|
+
filesystem tools to `--cwd`. `read-only` also forbids file edits. Both modes
|
|
108
|
+
reject shell commands because setting a command's working directory does not
|
|
109
|
+
isolate its filesystem access. To allow shell commands, explicitly pass
|
|
110
|
+
`--sandbox danger-full-access`; processes can then access files outside the workspace.
|
|
111
|
+
`--approval-policy on-request` asks before mutations; `never` skips those prompts.
|
|
112
|
+
Search and instruction discovery exclude links that escape their workspace or repository.
|
|
113
|
+
|
|
104
114
|
### Basic Setup
|
|
105
115
|
|
|
106
116
|
1. Set the `CODICENT_TOKEN` environment variable with your Codicent API token:
|
|
@@ -55,6 +55,16 @@ pip install git+https://github.com/izaxon/codicent-cli.git@v0.4.8
|
|
|
55
55
|
|
|
56
56
|
## Usage
|
|
57
57
|
|
|
58
|
+
### Coding agent sandbox
|
|
59
|
+
|
|
60
|
+
`codicent agent exec` defaults to `--sandbox workspace-write`, which confines
|
|
61
|
+
filesystem tools to `--cwd`. `read-only` also forbids file edits. Both modes
|
|
62
|
+
reject shell commands because setting a command's working directory does not
|
|
63
|
+
isolate its filesystem access. To allow shell commands, explicitly pass
|
|
64
|
+
`--sandbox danger-full-access`; processes can then access files outside the workspace.
|
|
65
|
+
`--approval-policy on-request` asks before mutations; `never` skips those prompts.
|
|
66
|
+
Search and instruction discovery exclude links that escape their workspace or repository.
|
|
67
|
+
|
|
58
68
|
### Basic Setup
|
|
59
69
|
|
|
60
70
|
1. Set the `CODICENT_TOKEN` environment variable with your Codicent API token:
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""`codicent agent` — standalone coding-agent mode (ADR-023).
|
|
2
|
+
|
|
3
|
+
The Codicent server is the reasoning layer (model, memory, tool loop); this
|
|
4
|
+
module is the local harness: workspace boundary, sandbox/approval enforcement,
|
|
5
|
+
local tool execution, session persistence, and structured stdout for
|
|
6
|
+
orchestrators like DINT. Rich is intentionally never imported here.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from agent_policy import (
|
|
19
|
+
AgentPolicy,
|
|
20
|
+
APPROVAL_NEVER,
|
|
21
|
+
APPROVAL_ON_REQUEST,
|
|
22
|
+
APPROVAL_POLICIES,
|
|
23
|
+
NEED_APPROVAL,
|
|
24
|
+
SANDBOX_FULL_ACCESS,
|
|
25
|
+
SANDBOX_LEVELS,
|
|
26
|
+
SANDBOX_WORKSPACE_WRITE,
|
|
27
|
+
)
|
|
28
|
+
from agent_protocol import (
|
|
29
|
+
EXIT_APPROVAL_REQUIRED,
|
|
30
|
+
EXIT_CANCELLED,
|
|
31
|
+
EXIT_ERROR,
|
|
32
|
+
EXIT_OK,
|
|
33
|
+
OUTPUT_FORMATS,
|
|
34
|
+
OUTPUT_JSONL,
|
|
35
|
+
OUTPUT_TEXT,
|
|
36
|
+
ProtocolEmitter,
|
|
37
|
+
)
|
|
38
|
+
from agent_session import AgentSession, SessionError
|
|
39
|
+
from agent_tools import STATUS_CANCELLED, STATUS_NEED_APPROVAL, ToolDispatcher
|
|
40
|
+
from agent_workspace import WorkspaceContext, WorkspaceError
|
|
41
|
+
|
|
42
|
+
POLL_INTERVAL_SECONDS = 1.5
|
|
43
|
+
DEFAULT_TURN_TIMEOUT = 600
|
|
44
|
+
|
|
45
|
+
DELEGATION_PREFIXES = (
|
|
46
|
+
"#execute_command\n",
|
|
47
|
+
"#fs_read\n",
|
|
48
|
+
"#fs_write\n",
|
|
49
|
+
"#fs_list\n",
|
|
50
|
+
"#fs_search\n",
|
|
51
|
+
"#apply_patch\n",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Wire protocol (StartAi2ChatAsync / GetMessageHistory / AddChatMessage)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
def decode_jwt_payload(token):
|
|
59
|
+
payload = token.split(".")[1]
|
|
60
|
+
payload += "=" * (-len(payload) % 4)
|
|
61
|
+
return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start_chat_async(base_url, token, project, message, conversation_id=None):
|
|
65
|
+
payload = {"message": message, "project": project}
|
|
66
|
+
if conversation_id:
|
|
67
|
+
payload["messageId"] = str(conversation_id)
|
|
68
|
+
resp = requests.post(
|
|
69
|
+
f"{base_url}app/StartAi2ChatAsync",
|
|
70
|
+
json=payload,
|
|
71
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
72
|
+
timeout=30,
|
|
73
|
+
)
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
return resp.json().get("promptMessageId")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fetch_history(base_url, token, message_id):
|
|
79
|
+
resp = requests.get(
|
|
80
|
+
f"{base_url}app/GetMessageHistory",
|
|
81
|
+
params={"messageId": str(message_id)},
|
|
82
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
83
|
+
timeout=15,
|
|
84
|
+
)
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
return resp.json() if resp.text else []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def post_child_message(base_url, token, project, parent_id, body):
|
|
90
|
+
resp = requests.post(
|
|
91
|
+
f"{base_url}app/AddChatMessage",
|
|
92
|
+
json={"content": f"@{project}\n{body}", "type": "ai-chat-answer2", "parentId": str(parent_id)},
|
|
93
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
94
|
+
timeout=30,
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
return resp.json()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def post_function_result(base_url, token, project, parent_id, function_name, result_content, tool_id):
|
|
101
|
+
fr = {"role": "function", "name": function_name, "content": result_content, "id": tool_id or ""}
|
|
102
|
+
return post_child_message(base_url, token, project, parent_id, f"#function_result\n{json.dumps(fr)}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def post_batch_results(base_url, token, project, parent_id, items):
|
|
106
|
+
"""items: original batch dicts with delegation contents replaced in place
|
|
107
|
+
(key casing preserved, so the server deserializes what it serialized)."""
|
|
108
|
+
return post_child_message(
|
|
109
|
+
base_url, token, project, parent_id, f"#function_results_batch\n{json.dumps(items)}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Delegation parsing
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
def strip_mention_and_tag(content, tag):
|
|
117
|
+
body = re.sub(r"^@\S+\s*", "", content).strip()
|
|
118
|
+
return re.sub(r"^" + re.escape(tag) + r"\s*\n?", "", body).strip()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_delegation_content(content):
|
|
122
|
+
"""If content starts with a delegation prefix, return (tool, data), else None."""
|
|
123
|
+
if not content:
|
|
124
|
+
return None
|
|
125
|
+
for prefix in DELEGATION_PREFIXES:
|
|
126
|
+
if content.startswith(prefix):
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(content[len(prefix):])
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None
|
|
131
|
+
return prefix.rstrip("\n").lstrip("#"), data
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_single_delegation(message_content):
|
|
136
|
+
"""Parse a #function_result message. Returns (name, tool, data, tool_id) or None."""
|
|
137
|
+
body = strip_mention_and_tag(message_content, "#function_result")
|
|
138
|
+
try:
|
|
139
|
+
fr = json.loads(body)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
parsed = parse_delegation_content(fr.get("content") or fr.get("Content") or "")
|
|
143
|
+
if not parsed:
|
|
144
|
+
return None
|
|
145
|
+
tool, data = parsed
|
|
146
|
+
return fr.get("name") or fr.get("Name") or tool, tool, data, fr.get("id") or fr.get("Id") or ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_batch_delegations(message_content):
|
|
150
|
+
"""Parse a #function_results_batch message.
|
|
151
|
+
|
|
152
|
+
Returns (items, delegations) where items is the original list of dicts and
|
|
153
|
+
delegations is a list of (index, content_key, tool, data); empty
|
|
154
|
+
delegations means the batch is already fully resolved.
|
|
155
|
+
Returns None if the content is not a valid batch.
|
|
156
|
+
"""
|
|
157
|
+
body = strip_mention_and_tag(message_content, "#function_results_batch")
|
|
158
|
+
try:
|
|
159
|
+
items = json.loads(body)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return None
|
|
162
|
+
if not isinstance(items, list):
|
|
163
|
+
return None
|
|
164
|
+
delegations = []
|
|
165
|
+
for index, item in enumerate(items):
|
|
166
|
+
if not isinstance(item, dict):
|
|
167
|
+
continue
|
|
168
|
+
content_key = "content" if "content" in item else ("Content" if "Content" in item else None)
|
|
169
|
+
if content_key is None:
|
|
170
|
+
continue
|
|
171
|
+
parsed = parse_delegation_content(item.get(content_key) or "")
|
|
172
|
+
if parsed:
|
|
173
|
+
tool, data = parsed
|
|
174
|
+
delegations.append((index, content_key, tool, data))
|
|
175
|
+
return items, delegations
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# CLI entry
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
def build_parser():
|
|
182
|
+
parser = argparse.ArgumentParser(
|
|
183
|
+
prog="codicent agent",
|
|
184
|
+
description="Run Codicent as a repository coding agent in the current folder.",
|
|
185
|
+
)
|
|
186
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
187
|
+
execp = sub.add_parser("exec", help="Execute one agent turn")
|
|
188
|
+
execp.add_argument("--cwd", default=None, help="Workspace root (default: current directory)")
|
|
189
|
+
execp.add_argument("--output-format", choices=OUTPUT_FORMATS, default=OUTPUT_TEXT)
|
|
190
|
+
execp.add_argument("--sandbox", choices=SANDBOX_LEVELS, default=SANDBOX_WORKSPACE_WRITE,
|
|
191
|
+
help="Filesystem access level; shell commands require danger-full-access")
|
|
192
|
+
execp.add_argument("--approval-policy", choices=APPROVAL_POLICIES, default=APPROVAL_ON_REQUEST)
|
|
193
|
+
execp.add_argument("--resume", default=None, metavar="SESSION_ID")
|
|
194
|
+
execp.add_argument("--prompt", default=None, help="The task prompt (or pass as positional text)")
|
|
195
|
+
execp.add_argument("--timeout", type=float, default=DEFAULT_TURN_TIMEOUT, help="Turn timeout in seconds")
|
|
196
|
+
execp.add_argument("prompt_words", nargs="*", help=argparse.SUPPRESS)
|
|
197
|
+
return parser
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _get_token():
|
|
201
|
+
from auth import CodicentAuth
|
|
202
|
+
|
|
203
|
+
token = CodicentAuth().get_cached_token()
|
|
204
|
+
if not token:
|
|
205
|
+
token = os.getenv("CODICENT_TOKEN")
|
|
206
|
+
return token
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_prompt_message(prompt, instructions):
|
|
210
|
+
if not instructions:
|
|
211
|
+
return prompt
|
|
212
|
+
parts = ["<repository-instructions>"]
|
|
213
|
+
for name, content in instructions:
|
|
214
|
+
parts.append(f"# file: {name}")
|
|
215
|
+
parts.append(content)
|
|
216
|
+
parts.append("</repository-instructions>")
|
|
217
|
+
parts.append("")
|
|
218
|
+
parts.append(prompt)
|
|
219
|
+
return "\n".join(parts)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_agent_command(argv):
|
|
223
|
+
parser = build_parser()
|
|
224
|
+
args = parser.parse_args(argv)
|
|
225
|
+
if args.subcommand != "exec":
|
|
226
|
+
parser.print_help(sys.stderr)
|
|
227
|
+
return EXIT_ERROR
|
|
228
|
+
|
|
229
|
+
emitter = ProtocolEmitter(output_format=args.output_format)
|
|
230
|
+
|
|
231
|
+
prompt = args.prompt or " ".join(args.prompt_words).strip()
|
|
232
|
+
if not prompt:
|
|
233
|
+
emitter.error("no prompt given (use --prompt)")
|
|
234
|
+
return EXIT_ERROR
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
workspace = WorkspaceContext(args.cwd)
|
|
238
|
+
except WorkspaceError as e:
|
|
239
|
+
emitter.error(str(e))
|
|
240
|
+
return EXIT_ERROR
|
|
241
|
+
|
|
242
|
+
token = _get_token()
|
|
243
|
+
if not token:
|
|
244
|
+
emitter.error("authentication required: run 'codicent auth' or set CODICENT_TOKEN")
|
|
245
|
+
return EXIT_ERROR
|
|
246
|
+
try:
|
|
247
|
+
jwt = decode_jwt_payload(token)
|
|
248
|
+
project = jwt["project"]
|
|
249
|
+
base_url = jwt.get("baseUrl", "https://codicent.com/")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
emitter.error(f"could not read project from token: {e}")
|
|
252
|
+
return EXIT_ERROR
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
if args.resume:
|
|
256
|
+
session = AgentSession.load(args.resume)
|
|
257
|
+
else:
|
|
258
|
+
session = AgentSession.new(workspace.root)
|
|
259
|
+
session.save()
|
|
260
|
+
except SessionError as e:
|
|
261
|
+
emitter.error(str(e))
|
|
262
|
+
return EXIT_ERROR
|
|
263
|
+
emitter.session_id = session.session_id
|
|
264
|
+
emitter.session()
|
|
265
|
+
|
|
266
|
+
policy = AgentPolicy(sandbox=args.sandbox, approval=args.approval_policy)
|
|
267
|
+
headless = args.output_format == OUTPUT_JSONL or not sys.stdin.isatty()
|
|
268
|
+
confirm_fn = None
|
|
269
|
+
if policy.approval == APPROVAL_ON_REQUEST and not headless:
|
|
270
|
+
def confirm_fn(tool, summary):
|
|
271
|
+
try:
|
|
272
|
+
answer = input(f"Allow {tool}: {summary}? (y/N) ")
|
|
273
|
+
except (KeyboardInterrupt, EOFError):
|
|
274
|
+
return False
|
|
275
|
+
return answer.strip().lower() == "y"
|
|
276
|
+
|
|
277
|
+
dispatcher = ToolDispatcher(workspace, policy, confirm_fn=confirm_fn)
|
|
278
|
+
|
|
279
|
+
if args.sandbox == SANDBOX_FULL_ACCESS:
|
|
280
|
+
emitter.log("WARNING: danger-full-access sandbox — no workspace confinement")
|
|
281
|
+
|
|
282
|
+
instructions = []
|
|
283
|
+
try:
|
|
284
|
+
instructions = workspace.discover_instructions()
|
|
285
|
+
if instructions:
|
|
286
|
+
emitter.log(f"instructions: {', '.join(name for name, _ in instructions)}")
|
|
287
|
+
except Exception as e:
|
|
288
|
+
emitter.log(f"instruction discovery failed: {e}")
|
|
289
|
+
|
|
290
|
+
message = build_prompt_message(prompt, instructions)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
return _run_turn(emitter, dispatcher, session, base_url, token, project, message, args.timeout, headless)
|
|
294
|
+
except KeyboardInterrupt:
|
|
295
|
+
dispatcher.cancel()
|
|
296
|
+
emitter.error("cancelled")
|
|
297
|
+
return EXIT_CANCELLED
|
|
298
|
+
except requests.RequestException as e:
|
|
299
|
+
emitter.error(f"server request failed: {e}")
|
|
300
|
+
return EXIT_ERROR
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _run_turn(emitter, dispatcher, session, base_url, token, project, message, timeout, headless):
|
|
304
|
+
prompt_id = start_chat_async(base_url, token, project, message, session.conversation_id)
|
|
305
|
+
if not prompt_id:
|
|
306
|
+
emitter.error("server did not return a prompt message id")
|
|
307
|
+
return EXIT_ERROR
|
|
308
|
+
session.touch(prompt_id)
|
|
309
|
+
|
|
310
|
+
deadline = time.time() + timeout
|
|
311
|
+
last_seen_id = None
|
|
312
|
+
|
|
313
|
+
while time.time() < deadline:
|
|
314
|
+
if dispatcher.cancelled:
|
|
315
|
+
emitter.error("cancelled")
|
|
316
|
+
return EXIT_CANCELLED
|
|
317
|
+
try:
|
|
318
|
+
history = fetch_history(base_url, token, prompt_id)
|
|
319
|
+
except requests.RequestException as e:
|
|
320
|
+
emitter.log(f"history fetch error: {e}")
|
|
321
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
if not history or len(history) < 2:
|
|
325
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
last = history[-1]
|
|
329
|
+
last_id = last.get("id") or last.get("Id")
|
|
330
|
+
content = last.get("content") or last.get("Content") or ""
|
|
331
|
+
|
|
332
|
+
if last_id == last_seen_id:
|
|
333
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
334
|
+
continue
|
|
335
|
+
last_seen_id = last_id
|
|
336
|
+
|
|
337
|
+
# Batch delegation (check before single: "#function_result" is a
|
|
338
|
+
# substring of "#function_results_batch").
|
|
339
|
+
if "#function_results_batch" in content:
|
|
340
|
+
if not dispatcher.already_handled(last_id):
|
|
341
|
+
parsed = parse_batch_delegations(content)
|
|
342
|
+
if parsed:
|
|
343
|
+
items, delegations = parsed
|
|
344
|
+
if delegations:
|
|
345
|
+
code = _handle_batch(
|
|
346
|
+
emitter, dispatcher, base_url, token, project, last_id, items, delegations, headless
|
|
347
|
+
)
|
|
348
|
+
if code is not None:
|
|
349
|
+
return code
|
|
350
|
+
dispatcher.mark_handled(last_id)
|
|
351
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
if "#function_result" in content:
|
|
355
|
+
if not dispatcher.already_handled(last_id):
|
|
356
|
+
delegation = parse_single_delegation(content)
|
|
357
|
+
if delegation:
|
|
358
|
+
name, tool, data, tool_id = delegation
|
|
359
|
+
code = _handle_single(
|
|
360
|
+
emitter, dispatcher, base_url, token, project, last_id, name, tool, data, tool_id, headless
|
|
361
|
+
)
|
|
362
|
+
if code is not None:
|
|
363
|
+
return code
|
|
364
|
+
dispatcher.mark_handled(last_id)
|
|
365
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
intermediate_markers = ("#chat2", "#finish_reason_function_call", "#finish_reason_tool_calls_batch")
|
|
369
|
+
if any(marker in content for marker in intermediate_markers):
|
|
370
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Final answer: strip @mention and leading tags for output.
|
|
374
|
+
clean = re.sub(r"^@\S+\s*", "", content).strip()
|
|
375
|
+
clean = re.sub(r"^(#\S+\s*)+", "", clean).strip()
|
|
376
|
+
session.touch(last_id)
|
|
377
|
+
emitter.text(clean)
|
|
378
|
+
emitter.done()
|
|
379
|
+
return EXIT_OK
|
|
380
|
+
|
|
381
|
+
emitter.error(f"turn timed out after {int(timeout)}s")
|
|
382
|
+
return EXIT_ERROR
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _handle_single(emitter, dispatcher, base_url, token, project, msg_id, name, tool, data, tool_id, headless):
|
|
386
|
+
"""Execute one delegated call and post the result. Returns an exit code to
|
|
387
|
+
terminate with, or None to continue polling."""
|
|
388
|
+
decision = dispatcher.evaluate(tool, data)
|
|
389
|
+
if decision.verdict == NEED_APPROVAL and headless:
|
|
390
|
+
emitter.need_approval(tool, dispatcher.describe(tool, data))
|
|
391
|
+
return EXIT_APPROVAL_REQUIRED
|
|
392
|
+
|
|
393
|
+
emitter.tool(tool, dispatcher.describe(tool, data))
|
|
394
|
+
result, status = dispatcher.dispatch(tool, data)
|
|
395
|
+
if status == STATUS_CANCELLED:
|
|
396
|
+
emitter.error("cancelled")
|
|
397
|
+
return EXIT_CANCELLED
|
|
398
|
+
if status == STATUS_NEED_APPROVAL:
|
|
399
|
+
emitter.need_approval(tool, dispatcher.describe(tool, data))
|
|
400
|
+
return EXIT_APPROVAL_REQUIRED
|
|
401
|
+
post_function_result(base_url, token, project, msg_id, name, result, tool_id)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _handle_batch(emitter, dispatcher, base_url, token, project, msg_id, items, delegations, headless):
|
|
406
|
+
"""Execute every delegated item in a batch and post the resolved batch.
|
|
407
|
+
Returns an exit code to terminate with, or None to continue polling."""
|
|
408
|
+
if headless:
|
|
409
|
+
pending = [
|
|
410
|
+
(tool, data)
|
|
411
|
+
for _index, _key, tool, data in delegations
|
|
412
|
+
if dispatcher.evaluate(tool, data).verdict == NEED_APPROVAL
|
|
413
|
+
]
|
|
414
|
+
if pending:
|
|
415
|
+
for tool, data in pending:
|
|
416
|
+
emitter.need_approval(tool, dispatcher.describe(tool, data))
|
|
417
|
+
return EXIT_APPROVAL_REQUIRED
|
|
418
|
+
|
|
419
|
+
for index, content_key, tool, data in delegations:
|
|
420
|
+
emitter.tool(tool, dispatcher.describe(tool, data))
|
|
421
|
+
result, status = dispatcher.dispatch(tool, data)
|
|
422
|
+
if status == STATUS_CANCELLED:
|
|
423
|
+
emitter.error("cancelled")
|
|
424
|
+
return EXIT_CANCELLED
|
|
425
|
+
if status == STATUS_NEED_APPROVAL:
|
|
426
|
+
emitter.need_approval(tool, dispatcher.describe(tool, data))
|
|
427
|
+
return EXIT_APPROVAL_REQUIRED
|
|
428
|
+
items[index][content_key] = result
|
|
429
|
+
|
|
430
|
+
post_batch_results(base_url, token, project, msg_id, items)
|
|
431
|
+
return None
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Sandbox and approval policy for agent mode (ADR-023 §6–§7).
|
|
2
|
+
|
|
3
|
+
Sandbox level and approval policy are independent axes: the sandbox says what
|
|
4
|
+
is reachable at all, the approval policy says what needs a human first.
|
|
5
|
+
Unknown tools are denied — the policy is fail-closed.
|
|
6
|
+
"""
|
|
7
|
+
from collections import namedtuple
|
|
8
|
+
|
|
9
|
+
from agent_workspace import WorkspaceEscapeError
|
|
10
|
+
|
|
11
|
+
SANDBOX_READ_ONLY = "read-only"
|
|
12
|
+
SANDBOX_WORKSPACE_WRITE = "workspace-write"
|
|
13
|
+
SANDBOX_FULL_ACCESS = "danger-full-access"
|
|
14
|
+
SANDBOX_LEVELS = (SANDBOX_READ_ONLY, SANDBOX_WORKSPACE_WRITE, SANDBOX_FULL_ACCESS)
|
|
15
|
+
|
|
16
|
+
APPROVAL_ON_REQUEST = "on-request"
|
|
17
|
+
APPROVAL_NEVER = "never"
|
|
18
|
+
APPROVAL_POLICIES = (APPROVAL_ON_REQUEST, APPROVAL_NEVER)
|
|
19
|
+
|
|
20
|
+
READ_TOOLS = frozenset({"fs_read", "fs_list", "fs_search"})
|
|
21
|
+
WRITE_TOOLS = frozenset({"fs_write", "apply_patch"})
|
|
22
|
+
COMMAND_TOOLS = frozenset({"execute_command"})
|
|
23
|
+
KNOWN_TOOLS = READ_TOOLS | WRITE_TOOLS | COMMAND_TOOLS
|
|
24
|
+
|
|
25
|
+
ALLOW = "allow"
|
|
26
|
+
DENY = "deny"
|
|
27
|
+
NEED_APPROVAL = "need_approval"
|
|
28
|
+
|
|
29
|
+
Decision = namedtuple("Decision", ["verdict", "reason"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgentPolicy:
|
|
33
|
+
def __init__(self, sandbox=SANDBOX_WORKSPACE_WRITE, approval=APPROVAL_ON_REQUEST):
|
|
34
|
+
if sandbox not in SANDBOX_LEVELS:
|
|
35
|
+
raise ValueError(f"unknown sandbox level: {sandbox}")
|
|
36
|
+
if approval not in APPROVAL_POLICIES:
|
|
37
|
+
raise ValueError(f"unknown approval policy: {approval}")
|
|
38
|
+
self.sandbox = sandbox
|
|
39
|
+
self.approval = approval
|
|
40
|
+
|
|
41
|
+
def evaluate(self, tool, data, workspace):
|
|
42
|
+
"""Return a Decision for one delegated tool call. Never raises."""
|
|
43
|
+
if tool not in KNOWN_TOOLS:
|
|
44
|
+
return Decision(DENY, f"unknown tool '{tool}'")
|
|
45
|
+
|
|
46
|
+
if tool in READ_TOOLS:
|
|
47
|
+
return self._check_path(data.get("path", ""), workspace)
|
|
48
|
+
|
|
49
|
+
if tool in WRITE_TOOLS:
|
|
50
|
+
if self.sandbox == SANDBOX_READ_ONLY:
|
|
51
|
+
return Decision(DENY, "read-only sandbox forbids filesystem writes")
|
|
52
|
+
if tool != "apply_patch":
|
|
53
|
+
# Paths are validated per-file when the patch is parsed; fs_write
|
|
54
|
+
# is checked here.
|
|
55
|
+
decision = self._check_path(data.get("path", ""), workspace)
|
|
56
|
+
if decision.verdict != ALLOW:
|
|
57
|
+
return decision
|
|
58
|
+
if self.approval == APPROVAL_ON_REQUEST:
|
|
59
|
+
return Decision(NEED_APPROVAL, "filesystem write requires approval")
|
|
60
|
+
return Decision(ALLOW, "")
|
|
61
|
+
|
|
62
|
+
# execute_command
|
|
63
|
+
if self.sandbox == SANDBOX_READ_ONLY:
|
|
64
|
+
return Decision(DENY, "read-only sandbox forbids shell commands")
|
|
65
|
+
if self.sandbox != SANDBOX_FULL_ACCESS:
|
|
66
|
+
return Decision(DENY, "shell commands require --sandbox danger-full-access; cwd confinement cannot sandbox a process")
|
|
67
|
+
if self.approval == APPROVAL_ON_REQUEST:
|
|
68
|
+
return Decision(NEED_APPROVAL, "command execution requires approval")
|
|
69
|
+
return Decision(ALLOW, "")
|
|
70
|
+
|
|
71
|
+
def _check_path(self, path, workspace):
|
|
72
|
+
if self.sandbox == SANDBOX_FULL_ACCESS:
|
|
73
|
+
return Decision(ALLOW, "")
|
|
74
|
+
try:
|
|
75
|
+
workspace.resolve(path)
|
|
76
|
+
except WorkspaceEscapeError as e:
|
|
77
|
+
return Decision(DENY, str(e))
|
|
78
|
+
return Decision(ALLOW, "")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Structured output protocol for `codicent agent exec` (ADR-023 §3–§4, frozen).
|
|
2
|
+
|
|
3
|
+
JSONL mode writes exactly one JSON object per stdout line and nothing else;
|
|
4
|
+
all diagnostics go to stderr. Text mode is a minimal human-readable rendering
|
|
5
|
+
(never Rich — Rich stays out of agent mode entirely).
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
EXIT_OK = 0
|
|
11
|
+
EXIT_ERROR = 1
|
|
12
|
+
EXIT_APPROVAL_REQUIRED = 2
|
|
13
|
+
EXIT_CANCELLED = 130
|
|
14
|
+
|
|
15
|
+
OUTPUT_JSONL = "jsonl"
|
|
16
|
+
OUTPUT_TEXT = "text"
|
|
17
|
+
OUTPUT_FORMATS = (OUTPUT_JSONL, OUTPUT_TEXT)
|
|
18
|
+
|
|
19
|
+
EVENT_TYPES = ("session", "tool", "need_approval", "text", "done", "error")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProtocolEmitter:
|
|
23
|
+
def __init__(self, session_id="", output_format=OUTPUT_TEXT, stdout=None, stderr=None):
|
|
24
|
+
if output_format not in OUTPUT_FORMATS:
|
|
25
|
+
raise ValueError(f"unknown output format: {output_format}")
|
|
26
|
+
self.session_id = session_id
|
|
27
|
+
self.output_format = output_format
|
|
28
|
+
self.stdout = stdout if stdout is not None else sys.stdout
|
|
29
|
+
self.stderr = stderr if stderr is not None else sys.stderr
|
|
30
|
+
|
|
31
|
+
def _emit(self, event_type, text=None, tool=None):
|
|
32
|
+
event = {"type": event_type, "session_id": self.session_id}
|
|
33
|
+
if tool is not None:
|
|
34
|
+
event["tool"] = tool
|
|
35
|
+
if text is not None:
|
|
36
|
+
event["text"] = text
|
|
37
|
+
if self.output_format == OUTPUT_JSONL:
|
|
38
|
+
self.stdout.write(json.dumps(event, ensure_ascii=False) + "\n")
|
|
39
|
+
else:
|
|
40
|
+
if event_type == "session":
|
|
41
|
+
self.stdout.write(f"[session {self.session_id}]\n")
|
|
42
|
+
elif event_type == "tool":
|
|
43
|
+
self.stdout.write(f"[tool] {tool}: {text}\n")
|
|
44
|
+
elif event_type == "need_approval":
|
|
45
|
+
self.stdout.write(f"[approval required] {tool}: {text}\n")
|
|
46
|
+
elif event_type == "text":
|
|
47
|
+
self.stdout.write(f"{text}\n")
|
|
48
|
+
elif event_type == "done":
|
|
49
|
+
self.stdout.write("[done]\n")
|
|
50
|
+
elif event_type == "error":
|
|
51
|
+
self.stdout.write(f"[error] {text}\n")
|
|
52
|
+
self.stdout.flush()
|
|
53
|
+
return event
|
|
54
|
+
|
|
55
|
+
def session(self):
|
|
56
|
+
return self._emit("session")
|
|
57
|
+
|
|
58
|
+
def tool(self, tool, text):
|
|
59
|
+
return self._emit("tool", text=text, tool=tool)
|
|
60
|
+
|
|
61
|
+
def need_approval(self, tool, text):
|
|
62
|
+
return self._emit("need_approval", text=text, tool=tool)
|
|
63
|
+
|
|
64
|
+
def text(self, text):
|
|
65
|
+
return self._emit("text", text=text)
|
|
66
|
+
|
|
67
|
+
def done(self):
|
|
68
|
+
return self._emit("done")
|
|
69
|
+
|
|
70
|
+
def error(self, text):
|
|
71
|
+
return self._emit("error", text=text)
|
|
72
|
+
|
|
73
|
+
def log(self, text):
|
|
74
|
+
"""Diagnostic line — stderr only, never stdout."""
|
|
75
|
+
self.stderr.write(f"{text}\n")
|
|
76
|
+
self.stderr.flush()
|