agent-message 0.1.2 → 0.1.4
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.
- package/README.md +103 -22
- package/codex-message/Cargo.lock +1867 -0
- package/codex-message/Cargo.toml +17 -0
- package/codex-message/README.md +29 -0
- package/codex-message/src/agent_message.rs +205 -0
- package/codex-message/src/app.rs +857 -0
- package/codex-message/src/codex.rs +278 -0
- package/codex-message/src/main.rs +101 -0
- package/codex-message/src/render.rs +125 -0
- package/npm/bin/agent-message.mjs +245 -13
- package/npm/bin/codex-message.mjs +41 -0
- package/npm/runtime/agent_gateway.mjs +5 -2
- package/npm/runtime/bin/agent-message-cli-darwin-amd64 +0 -0
- package/npm/runtime/bin/agent-message-cli-darwin-arm64 +0 -0
- package/npm/runtime/bin/agent-message-server-darwin-amd64 +0 -0
- package/npm/runtime/bin/agent-message-server-darwin-arm64 +0 -0
- package/npm/runtime/web-dist/assets/index-AfnEJAni.css +1 -0
- package/npm/runtime/web-dist/assets/index-QUUfdfoN.js +182 -0
- package/npm/runtime/web-dist/index.html +2 -2
- package/npm/runtime/web-dist/sw.js +2 -1
- package/package.json +4 -2
- package/npm/runtime/web-dist/assets/index-4VmoBZF3.js +0 -182
- package/npm/runtime/web-dist/assets/index-D_RPU5JN.css +0 -1
- package/npm/runtime/web-dist/workbox-8c29f6e4.js +0 -1
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
use std::collections::HashSet;
|
|
2
|
+
use std::time::Duration;
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result, anyhow};
|
|
5
|
+
use rand::Rng;
|
|
6
|
+
use serde_json::Map;
|
|
7
|
+
use serde_json::Value;
|
|
8
|
+
use serde_json::json;
|
|
9
|
+
use uuid::Uuid;
|
|
10
|
+
|
|
11
|
+
use crate::Config;
|
|
12
|
+
use crate::SandboxArg;
|
|
13
|
+
use crate::agent_message::{AgentMessageClient, Message};
|
|
14
|
+
use crate::codex::{CodexAppServer, IncomingMessage};
|
|
15
|
+
use crate::render::{approval_spec, report_spec};
|
|
16
|
+
|
|
17
|
+
const REQUEST_SUFFIX: &str = r#"
|
|
18
|
+
|
|
19
|
+
Operational requirements from the codex-message wrapper:
|
|
20
|
+
- The final result will be forwarded to the user through agent-message.
|
|
21
|
+
- Prefer a visually readable report format suitable for an agent-message json-render response.
|
|
22
|
+
- If you need approval or clarification, ask clearly and briefly so the wrapper can relay it.
|
|
23
|
+
"#;
|
|
24
|
+
|
|
25
|
+
pub(crate) struct App {
|
|
26
|
+
config: Config,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl App {
|
|
30
|
+
pub(crate) fn new(config: Config) -> Self {
|
|
31
|
+
Self { config }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub(crate) async fn run(self) -> Result<()> {
|
|
35
|
+
let mut runtime = Runtime::bootstrap(self.config).await?;
|
|
36
|
+
runtime.run_loop().await
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
struct Runtime {
|
|
41
|
+
config: Config,
|
|
42
|
+
chat_id: String,
|
|
43
|
+
to_username: String,
|
|
44
|
+
agent_client: AgentMessageClient,
|
|
45
|
+
seen_message_ids: HashSet<String>,
|
|
46
|
+
codex: CodexAppServer,
|
|
47
|
+
thread_id: String,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl Runtime {
|
|
51
|
+
async fn bootstrap(config: Config) -> Result<Self> {
|
|
52
|
+
let chat_id = new_chat_id();
|
|
53
|
+
let username = format!("agent-{chat_id}");
|
|
54
|
+
let pin = new_pin();
|
|
55
|
+
let to_username = config.to_username.clone();
|
|
56
|
+
let agent_client = AgentMessageClient::new(std::path::PathBuf::from("agent-message"));
|
|
57
|
+
|
|
58
|
+
register_agent_account(&agent_client, &username, &pin).await?;
|
|
59
|
+
println!(
|
|
60
|
+
"registered agent profile: {username} (chat_id: {chat_id})"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
let startup_text = format!(
|
|
64
|
+
"codex-message session started\nchat_id: {chat_id}\nusername: {username}\npin: {pin}\n\nReply in this DM to run Codex."
|
|
65
|
+
);
|
|
66
|
+
let startup_message_id = agent_client
|
|
67
|
+
.send_text_message(&to_username, &startup_text)
|
|
68
|
+
.await
|
|
69
|
+
.context("send startup message")?;
|
|
70
|
+
println!(
|
|
71
|
+
"startup message sent to {to_username}: {startup_message_id}"
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
let codex = CodexAppServer::start(&config.codex_bin, &config.cwd)
|
|
75
|
+
.await
|
|
76
|
+
.context("start codex app-server")?;
|
|
77
|
+
codex
|
|
78
|
+
.initialize()
|
|
79
|
+
.await
|
|
80
|
+
.context("initialize codex app-server")?;
|
|
81
|
+
let thread_id = start_thread(&codex, &config).await?;
|
|
82
|
+
println!("codex app-server ready (thread_id: {thread_id})");
|
|
83
|
+
|
|
84
|
+
Ok(Self {
|
|
85
|
+
config,
|
|
86
|
+
chat_id,
|
|
87
|
+
to_username,
|
|
88
|
+
agent_client,
|
|
89
|
+
seen_message_ids: HashSet::new(),
|
|
90
|
+
codex,
|
|
91
|
+
thread_id,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async fn run_loop(&mut self) -> Result<()> {
|
|
96
|
+
loop {
|
|
97
|
+
tokio::select! {
|
|
98
|
+
_ = tokio::signal::ctrl_c() => {
|
|
99
|
+
self.codex.shutdown().await?;
|
|
100
|
+
return Ok(());
|
|
101
|
+
}
|
|
102
|
+
next = self.next_target_message() => {
|
|
103
|
+
let message = next?;
|
|
104
|
+
let Some(request) = extract_request_text(&message) else {
|
|
105
|
+
continue;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
match self.run_turn(&request).await {
|
|
109
|
+
Ok(outcome) => {
|
|
110
|
+
let spec = report_spec(
|
|
111
|
+
match outcome.status.as_str() {
|
|
112
|
+
"completed" => "Completed",
|
|
113
|
+
"interrupted" => "Interrupted",
|
|
114
|
+
_ => "Failed",
|
|
115
|
+
},
|
|
116
|
+
"Codex report",
|
|
117
|
+
&[
|
|
118
|
+
format!("Chat ID: {}", self.chat_id),
|
|
119
|
+
format!("Request: {}", request.trim()),
|
|
120
|
+
format!("Status: {}", outcome.status),
|
|
121
|
+
],
|
|
122
|
+
Some(&outcome.report_body()),
|
|
123
|
+
);
|
|
124
|
+
self.agent_client
|
|
125
|
+
.send_json_render_message(&self.to_username, spec)
|
|
126
|
+
.await
|
|
127
|
+
.context("send turn report")?;
|
|
128
|
+
}
|
|
129
|
+
Err(error) => {
|
|
130
|
+
let spec = report_spec(
|
|
131
|
+
"Error",
|
|
132
|
+
"Codex request failed",
|
|
133
|
+
&[
|
|
134
|
+
format!("Chat ID: {}", self.chat_id),
|
|
135
|
+
format!("Request: {}", request.trim()),
|
|
136
|
+
],
|
|
137
|
+
Some(&error.to_string()),
|
|
138
|
+
);
|
|
139
|
+
self.agent_client
|
|
140
|
+
.send_json_render_message(&self.to_username, spec)
|
|
141
|
+
.await
|
|
142
|
+
.context("send failure report")?;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async fn next_target_message(&mut self) -> Result<Message> {
|
|
151
|
+
loop {
|
|
152
|
+
let messages = self
|
|
153
|
+
.agent_client
|
|
154
|
+
.read_messages(&self.to_username, 20)
|
|
155
|
+
.await
|
|
156
|
+
.context("poll messages from agent-message")?;
|
|
157
|
+
|
|
158
|
+
for message in messages.into_iter().rev() {
|
|
159
|
+
if !self.seen_message_ids.insert(message.id.clone()) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if !message
|
|
163
|
+
.sender_username
|
|
164
|
+
.eq_ignore_ascii_case(&self.to_username)
|
|
165
|
+
{
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if extract_request_text(&message).is_none() {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
return Ok(message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async fn run_turn(&mut self, request: &str) -> Result<TurnOutcome> {
|
|
179
|
+
let composed_request = format!("{}\n{}", request.trim(), REQUEST_SUFFIX);
|
|
180
|
+
let turn_params =
|
|
181
|
+
build_turn_start_params(&self.config, &self.thread_id, &composed_request)?;
|
|
182
|
+
let response = self
|
|
183
|
+
.codex
|
|
184
|
+
.request("turn/start", turn_params)
|
|
185
|
+
.await
|
|
186
|
+
.context("start codex turn")?;
|
|
187
|
+
let turn_id = response
|
|
188
|
+
.get("turn")
|
|
189
|
+
.and_then(|turn| turn.get("id"))
|
|
190
|
+
.and_then(Value::as_str)
|
|
191
|
+
.map(ToOwned::to_owned)
|
|
192
|
+
.ok_or_else(|| anyhow!("turn/start response missing turn.id"))?;
|
|
193
|
+
|
|
194
|
+
let mut agent_text = String::new();
|
|
195
|
+
|
|
196
|
+
let (turn_status, turn_error) = loop {
|
|
197
|
+
match self.codex.next_event().await? {
|
|
198
|
+
IncomingMessage::Notification { method, params } => match method.as_str() {
|
|
199
|
+
"item/agentMessage/delta" => {
|
|
200
|
+
if params.get("turnId").and_then(Value::as_str) == Some(turn_id.as_str()) {
|
|
201
|
+
if let Some(delta) = params.get("delta").and_then(Value::as_str) {
|
|
202
|
+
agent_text.push_str(delta);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
"turn/completed" => {
|
|
207
|
+
if params
|
|
208
|
+
.get("turn")
|
|
209
|
+
.and_then(|turn| turn.get("id"))
|
|
210
|
+
.and_then(Value::as_str)
|
|
211
|
+
!= Some(turn_id.as_str())
|
|
212
|
+
{
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let status = params
|
|
217
|
+
.get("turn")
|
|
218
|
+
.and_then(|turn| turn.get("status"))
|
|
219
|
+
.and_then(Value::as_str)
|
|
220
|
+
.map(ToOwned::to_owned)
|
|
221
|
+
.unwrap_or_else(|| "unknown".to_string());
|
|
222
|
+
let error = params
|
|
223
|
+
.get("turn")
|
|
224
|
+
.and_then(|turn| turn.get("error"))
|
|
225
|
+
.and_then(|error| error.get("message"))
|
|
226
|
+
.and_then(Value::as_str)
|
|
227
|
+
.map(ToOwned::to_owned);
|
|
228
|
+
break (status, error);
|
|
229
|
+
}
|
|
230
|
+
_ => {}
|
|
231
|
+
},
|
|
232
|
+
IncomingMessage::Request { method, id, params } => {
|
|
233
|
+
self.handle_server_request(method.as_str(), id, params)
|
|
234
|
+
.await?;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
Ok(TurnOutcome {
|
|
240
|
+
status: turn_status,
|
|
241
|
+
agent_text: agent_text.trim().to_string(),
|
|
242
|
+
error_text: turn_error,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async fn handle_server_request(
|
|
247
|
+
&mut self,
|
|
248
|
+
method: &str,
|
|
249
|
+
id: Value,
|
|
250
|
+
params: Value,
|
|
251
|
+
) -> Result<()> {
|
|
252
|
+
match method {
|
|
253
|
+
"item/commandExecution/requestApproval" => {
|
|
254
|
+
let details = summarize_command_approval(¶ms);
|
|
255
|
+
let spec = approval_spec(
|
|
256
|
+
"Approval Needed",
|
|
257
|
+
"Command approval requested",
|
|
258
|
+
&details,
|
|
259
|
+
"approve | session | deny | cancel",
|
|
260
|
+
);
|
|
261
|
+
self.agent_client
|
|
262
|
+
.send_json_render_message(&self.to_username, spec)
|
|
263
|
+
.await
|
|
264
|
+
.context("send command approval request")?;
|
|
265
|
+
|
|
266
|
+
let decision = loop {
|
|
267
|
+
let reply = self.next_target_message().await?;
|
|
268
|
+
let Some(text) = extract_request_text(&reply) else {
|
|
269
|
+
continue;
|
|
270
|
+
};
|
|
271
|
+
if let Some(decision) = parse_command_decision(&text) {
|
|
272
|
+
break decision;
|
|
273
|
+
}
|
|
274
|
+
self.agent_client
|
|
275
|
+
.send_text_message(
|
|
276
|
+
&self.to_username,
|
|
277
|
+
"Reply with one of: approve, session, deny, cancel.",
|
|
278
|
+
)
|
|
279
|
+
.await
|
|
280
|
+
.context("send command approval clarification")?;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
self.codex
|
|
284
|
+
.respond(id, json!({ "decision": decision }))
|
|
285
|
+
.await
|
|
286
|
+
.context("respond to command approval")?;
|
|
287
|
+
}
|
|
288
|
+
"item/fileChange/requestApproval" => {
|
|
289
|
+
let details = summarize_file_approval(¶ms);
|
|
290
|
+
let spec = approval_spec(
|
|
291
|
+
"Approval Needed",
|
|
292
|
+
"File change approval requested",
|
|
293
|
+
&details,
|
|
294
|
+
"approve | session | deny | cancel",
|
|
295
|
+
);
|
|
296
|
+
self.agent_client
|
|
297
|
+
.send_json_render_message(&self.to_username, spec)
|
|
298
|
+
.await
|
|
299
|
+
.context("send file approval request")?;
|
|
300
|
+
|
|
301
|
+
let decision = loop {
|
|
302
|
+
let reply = self.next_target_message().await?;
|
|
303
|
+
let Some(text) = extract_request_text(&reply) else {
|
|
304
|
+
continue;
|
|
305
|
+
};
|
|
306
|
+
if let Some(decision) = parse_file_decision(&text) {
|
|
307
|
+
break decision;
|
|
308
|
+
}
|
|
309
|
+
self.agent_client
|
|
310
|
+
.send_text_message(
|
|
311
|
+
&self.to_username,
|
|
312
|
+
"Reply with one of: approve, session, deny, cancel.",
|
|
313
|
+
)
|
|
314
|
+
.await
|
|
315
|
+
.context("send file approval clarification")?;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
self.codex
|
|
319
|
+
.respond(id, json!({ "decision": decision }))
|
|
320
|
+
.await
|
|
321
|
+
.context("respond to file approval")?;
|
|
322
|
+
}
|
|
323
|
+
"item/permissions/requestApproval" => {
|
|
324
|
+
let details = summarize_permissions_request(¶ms);
|
|
325
|
+
let requested_permissions = params
|
|
326
|
+
.get("permissions")
|
|
327
|
+
.cloned()
|
|
328
|
+
.unwrap_or_else(|| json!({}));
|
|
329
|
+
let spec = approval_spec(
|
|
330
|
+
"Permission Needed",
|
|
331
|
+
"Additional permissions requested",
|
|
332
|
+
&details,
|
|
333
|
+
"allow | allow session | deny",
|
|
334
|
+
);
|
|
335
|
+
self.agent_client
|
|
336
|
+
.send_json_render_message(&self.to_username, spec)
|
|
337
|
+
.await
|
|
338
|
+
.context("send permissions request")?;
|
|
339
|
+
|
|
340
|
+
let response = loop {
|
|
341
|
+
let reply = self.next_target_message().await?;
|
|
342
|
+
let Some(text) = extract_request_text(&reply) else {
|
|
343
|
+
continue;
|
|
344
|
+
};
|
|
345
|
+
if let Some(response) = parse_permission_response(&text, &requested_permissions)
|
|
346
|
+
{
|
|
347
|
+
break response;
|
|
348
|
+
}
|
|
349
|
+
self.agent_client
|
|
350
|
+
.send_text_message(
|
|
351
|
+
&self.to_username,
|
|
352
|
+
"Reply with one of: allow, allow session, deny.",
|
|
353
|
+
)
|
|
354
|
+
.await
|
|
355
|
+
.context("send permissions clarification")?;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
self.codex
|
|
359
|
+
.respond(id, response)
|
|
360
|
+
.await
|
|
361
|
+
.context("respond to permissions request")?;
|
|
362
|
+
}
|
|
363
|
+
"item/tool/requestUserInput" => {
|
|
364
|
+
let questions = params
|
|
365
|
+
.get("questions")
|
|
366
|
+
.and_then(Value::as_array)
|
|
367
|
+
.cloned()
|
|
368
|
+
.unwrap_or_default();
|
|
369
|
+
let details = summarize_tool_questions(&questions);
|
|
370
|
+
let spec = approval_spec(
|
|
371
|
+
"Input Needed",
|
|
372
|
+
"Codex requested user input",
|
|
373
|
+
&details,
|
|
374
|
+
"JSON object keyed by question id, or plain text if there is only one question",
|
|
375
|
+
);
|
|
376
|
+
self.agent_client
|
|
377
|
+
.send_json_render_message(&self.to_username, spec)
|
|
378
|
+
.await
|
|
379
|
+
.context("send request_user_input prompt")?;
|
|
380
|
+
|
|
381
|
+
let response = loop {
|
|
382
|
+
let reply = self.next_target_message().await?;
|
|
383
|
+
let Some(text) = extract_request_text(&reply) else {
|
|
384
|
+
continue;
|
|
385
|
+
};
|
|
386
|
+
if let Some(response) = parse_tool_user_input_response(&text, &questions) {
|
|
387
|
+
break response;
|
|
388
|
+
}
|
|
389
|
+
self.agent_client
|
|
390
|
+
.send_text_message(
|
|
391
|
+
&self.to_username,
|
|
392
|
+
"Reply with JSON like {\"question_id\":\"answer\"}. For a single question, plain text also works.",
|
|
393
|
+
)
|
|
394
|
+
.await
|
|
395
|
+
.context("send request_user_input clarification")?;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
self.codex
|
|
399
|
+
.respond(id, response)
|
|
400
|
+
.await
|
|
401
|
+
.context("respond to request_user_input")?;
|
|
402
|
+
}
|
|
403
|
+
"mcpServer/elicitation/request" => {
|
|
404
|
+
let details = summarize_mcp_elicitation(¶ms);
|
|
405
|
+
let spec = approval_spec(
|
|
406
|
+
"MCP Input",
|
|
407
|
+
"An MCP server requested interaction",
|
|
408
|
+
&details,
|
|
409
|
+
"accept | decline | cancel, optionally followed by JSON content",
|
|
410
|
+
);
|
|
411
|
+
self.agent_client
|
|
412
|
+
.send_json_render_message(&self.to_username, spec)
|
|
413
|
+
.await
|
|
414
|
+
.context("send MCP elicitation prompt")?;
|
|
415
|
+
|
|
416
|
+
let response = loop {
|
|
417
|
+
let reply = self.next_target_message().await?;
|
|
418
|
+
let Some(text) = extract_request_text(&reply) else {
|
|
419
|
+
continue;
|
|
420
|
+
};
|
|
421
|
+
if let Some(response) = parse_mcp_elicitation_response(&text) {
|
|
422
|
+
break response;
|
|
423
|
+
}
|
|
424
|
+
self.agent_client
|
|
425
|
+
.send_text_message(
|
|
426
|
+
&self.to_username,
|
|
427
|
+
"Reply with `accept`, `decline`, or `cancel`. You can append JSON after `accept` if the MCP server needs content.",
|
|
428
|
+
)
|
|
429
|
+
.await
|
|
430
|
+
.context("send MCP clarification")?;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
self.codex
|
|
434
|
+
.respond(id, response)
|
|
435
|
+
.await
|
|
436
|
+
.context("respond to MCP elicitation")?;
|
|
437
|
+
}
|
|
438
|
+
other => {
|
|
439
|
+
let spec = report_spec(
|
|
440
|
+
"Unsupported",
|
|
441
|
+
"Unhandled Codex interaction",
|
|
442
|
+
&[format!("Method: {other}")],
|
|
443
|
+
Some("codex-message does not implement this server request type yet."),
|
|
444
|
+
);
|
|
445
|
+
self.agent_client
|
|
446
|
+
.send_json_render_message(&self.to_username, spec)
|
|
447
|
+
.await
|
|
448
|
+
.context("send unsupported interaction notice")?;
|
|
449
|
+
self.codex
|
|
450
|
+
.respond(id, json!({}))
|
|
451
|
+
.await
|
|
452
|
+
.context("respond to unsupported request with empty object")?;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
Ok(())
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#[derive(Debug)]
|
|
461
|
+
struct TurnOutcome {
|
|
462
|
+
status: String,
|
|
463
|
+
agent_text: String,
|
|
464
|
+
error_text: Option<String>,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
impl TurnOutcome {
|
|
468
|
+
fn report_body(&self) -> String {
|
|
469
|
+
match (&self.agent_text.is_empty(), &self.error_text) {
|
|
470
|
+
(false, Some(error)) => format!("{}\n\nError: {}", self.agent_text, error),
|
|
471
|
+
(false, None) => self.agent_text.clone(),
|
|
472
|
+
(true, Some(error)) => error.clone(),
|
|
473
|
+
(true, None) => "Codex completed without an assistant message.".to_string(),
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async fn register_agent_account(
|
|
479
|
+
client: &AgentMessageClient,
|
|
480
|
+
username: &str,
|
|
481
|
+
pin: &str,
|
|
482
|
+
) -> Result<()> {
|
|
483
|
+
client
|
|
484
|
+
.register(username, pin)
|
|
485
|
+
.await
|
|
486
|
+
.context("register agent-message account")
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async fn start_thread(codex: &CodexAppServer, config: &Config) -> Result<String> {
|
|
490
|
+
let mut params = Map::new();
|
|
491
|
+
params.insert(
|
|
492
|
+
"cwd".to_string(),
|
|
493
|
+
Value::String(config.cwd.to_string_lossy().into_owned()),
|
|
494
|
+
);
|
|
495
|
+
if let Some(model) = &config.model {
|
|
496
|
+
params.insert("model".to_string(), Value::String(model.clone()));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let response = codex
|
|
500
|
+
.request("thread/start", Value::Object(params))
|
|
501
|
+
.await
|
|
502
|
+
.context("start codex thread")?;
|
|
503
|
+
response
|
|
504
|
+
.get("thread")
|
|
505
|
+
.and_then(|thread| thread.get("id"))
|
|
506
|
+
.and_then(Value::as_str)
|
|
507
|
+
.map(ToOwned::to_owned)
|
|
508
|
+
.ok_or_else(|| anyhow!("thread/start response missing thread.id"))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
fn build_turn_start_params(config: &Config, thread_id: &str, text: &str) -> Result<Value> {
|
|
512
|
+
let mut params = Map::new();
|
|
513
|
+
params.insert("threadId".to_string(), Value::String(thread_id.to_string()));
|
|
514
|
+
params.insert(
|
|
515
|
+
"input".to_string(),
|
|
516
|
+
Value::Array(vec![json!({
|
|
517
|
+
"type": "text",
|
|
518
|
+
"text": text,
|
|
519
|
+
})]),
|
|
520
|
+
);
|
|
521
|
+
params.insert(
|
|
522
|
+
"cwd".to_string(),
|
|
523
|
+
Value::String(config.cwd.to_string_lossy().into_owned()),
|
|
524
|
+
);
|
|
525
|
+
if let Some(model) = &config.model {
|
|
526
|
+
params.insert("model".to_string(), Value::String(model.clone()));
|
|
527
|
+
}
|
|
528
|
+
if let Some(policy) = &config.approval_policy {
|
|
529
|
+
params.insert("approvalPolicy".to_string(), Value::String(policy.clone()));
|
|
530
|
+
}
|
|
531
|
+
params.insert("sandboxPolicy".to_string(), sandbox_policy(config));
|
|
532
|
+
Ok(Value::Object(params))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn sandbox_policy(config: &Config) -> Value {
|
|
536
|
+
match config.sandbox {
|
|
537
|
+
SandboxArg::ReadOnly => json!({
|
|
538
|
+
"type": "readOnly",
|
|
539
|
+
"networkAccess": config.network_access,
|
|
540
|
+
}),
|
|
541
|
+
SandboxArg::WorkspaceWrite => json!({
|
|
542
|
+
"type": "workspaceWrite",
|
|
543
|
+
"writableRoots": [config.cwd.to_string_lossy()],
|
|
544
|
+
"networkAccess": config.network_access,
|
|
545
|
+
}),
|
|
546
|
+
SandboxArg::DangerFullAccess => json!({
|
|
547
|
+
"type": "dangerFullAccess",
|
|
548
|
+
}),
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
fn extract_request_text(message: &Message) -> Option<String> {
|
|
553
|
+
let trimmed = message.text.trim();
|
|
554
|
+
if trimmed.is_empty() {
|
|
555
|
+
return None;
|
|
556
|
+
}
|
|
557
|
+
if trimmed == "[json-render]" || trimmed == "deleted message" {
|
|
558
|
+
return None;
|
|
559
|
+
}
|
|
560
|
+
Some(trimmed.to_string())
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
fn summarize_command_approval(params: &Value) -> Vec<String> {
|
|
564
|
+
let mut details = Vec::new();
|
|
565
|
+
if let Some(reason) = params.get("reason").and_then(Value::as_str) {
|
|
566
|
+
details.push(format!("Reason: {reason}"));
|
|
567
|
+
}
|
|
568
|
+
if let Some(command) = params.get("command").and_then(Value::as_str) {
|
|
569
|
+
details.push(format!("Command: {command}"));
|
|
570
|
+
}
|
|
571
|
+
if let Some(cwd) = params.get("cwd").and_then(Value::as_str) {
|
|
572
|
+
details.push(format!("CWD: {cwd}"));
|
|
573
|
+
}
|
|
574
|
+
if let Some(permissions) = params.get("additionalPermissions") {
|
|
575
|
+
details.push(format!(
|
|
576
|
+
"Additional permissions: {}",
|
|
577
|
+
serde_json::to_string_pretty(permissions).unwrap_or_else(|_| permissions.to_string())
|
|
578
|
+
));
|
|
579
|
+
}
|
|
580
|
+
if details.is_empty() {
|
|
581
|
+
details.push("Codex requested command execution approval.".to_string());
|
|
582
|
+
}
|
|
583
|
+
details
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
fn summarize_file_approval(params: &Value) -> Vec<String> {
|
|
587
|
+
let mut details = Vec::new();
|
|
588
|
+
if let Some(reason) = params.get("reason").and_then(Value::as_str) {
|
|
589
|
+
details.push(format!("Reason: {reason}"));
|
|
590
|
+
}
|
|
591
|
+
if let Some(root) = params.get("grantRoot").and_then(Value::as_str) {
|
|
592
|
+
details.push(format!("Grant root: {root}"));
|
|
593
|
+
}
|
|
594
|
+
if details.is_empty() {
|
|
595
|
+
details.push("Codex requested approval for file changes.".to_string());
|
|
596
|
+
}
|
|
597
|
+
details
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
fn summarize_permissions_request(params: &Value) -> Vec<String> {
|
|
601
|
+
let mut details = Vec::new();
|
|
602
|
+
if let Some(reason) = params.get("reason").and_then(Value::as_str) {
|
|
603
|
+
details.push(format!("Reason: {reason}"));
|
|
604
|
+
}
|
|
605
|
+
if let Some(permissions) = params.get("permissions") {
|
|
606
|
+
details.push(format!(
|
|
607
|
+
"Requested permissions: {}",
|
|
608
|
+
serde_json::to_string_pretty(permissions).unwrap_or_else(|_| permissions.to_string())
|
|
609
|
+
));
|
|
610
|
+
}
|
|
611
|
+
if details.is_empty() {
|
|
612
|
+
details.push("Codex requested additional permissions.".to_string());
|
|
613
|
+
}
|
|
614
|
+
details
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
fn summarize_tool_questions(questions: &[Value]) -> Vec<String> {
|
|
618
|
+
let mut details = Vec::new();
|
|
619
|
+
for question in questions {
|
|
620
|
+
let id = question
|
|
621
|
+
.get("id")
|
|
622
|
+
.and_then(Value::as_str)
|
|
623
|
+
.unwrap_or("question");
|
|
624
|
+
let text = question
|
|
625
|
+
.get("question")
|
|
626
|
+
.and_then(Value::as_str)
|
|
627
|
+
.unwrap_or("No question text provided");
|
|
628
|
+
details.push(format!("{id}: {text}"));
|
|
629
|
+
if let Some(options) = question.get("options").and_then(Value::as_array) {
|
|
630
|
+
let labels: Vec<String> = options
|
|
631
|
+
.iter()
|
|
632
|
+
.filter_map(|option| option.get("label").and_then(Value::as_str))
|
|
633
|
+
.map(ToOwned::to_owned)
|
|
634
|
+
.collect();
|
|
635
|
+
if !labels.is_empty() {
|
|
636
|
+
details.push(format!("Options for {id}: {}", labels.join(", ")));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if details.is_empty() {
|
|
641
|
+
details.push("Codex requested additional user input.".to_string());
|
|
642
|
+
}
|
|
643
|
+
details
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
fn summarize_mcp_elicitation(params: &Value) -> Vec<String> {
|
|
647
|
+
let mut details = Vec::new();
|
|
648
|
+
if let Some(server_name) = params.get("serverName").and_then(Value::as_str) {
|
|
649
|
+
details.push(format!("Server: {server_name}"));
|
|
650
|
+
}
|
|
651
|
+
if let Some(mode) = params.get("mode").and_then(Value::as_str) {
|
|
652
|
+
details.push(format!("Mode: {mode}"));
|
|
653
|
+
}
|
|
654
|
+
if let Some(message) = params.get("message").and_then(Value::as_str) {
|
|
655
|
+
details.push(format!("Message: {message}"));
|
|
656
|
+
}
|
|
657
|
+
if let Some(url) = params.get("url").and_then(Value::as_str) {
|
|
658
|
+
details.push(format!("URL: {url}"));
|
|
659
|
+
}
|
|
660
|
+
if details.is_empty() {
|
|
661
|
+
details.push("An MCP server requested interaction.".to_string());
|
|
662
|
+
}
|
|
663
|
+
details
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
fn parse_command_decision(text: &str) -> Option<Value> {
|
|
667
|
+
let normalized = normalize_reply(text);
|
|
668
|
+
if normalized.contains("cancel") || normalized.contains("abort") {
|
|
669
|
+
return Some(json!("cancel"));
|
|
670
|
+
}
|
|
671
|
+
if normalized.contains("session") {
|
|
672
|
+
return Some(json!("acceptForSession"));
|
|
673
|
+
}
|
|
674
|
+
if normalized.contains("deny") || normalized.contains("decline") || normalized == "no" {
|
|
675
|
+
return Some(json!("decline"));
|
|
676
|
+
}
|
|
677
|
+
if normalized.contains("approve")
|
|
678
|
+
|| normalized.contains("accept")
|
|
679
|
+
|| normalized.contains("allow")
|
|
680
|
+
|| normalized == "yes"
|
|
681
|
+
{
|
|
682
|
+
return Some(json!("accept"));
|
|
683
|
+
}
|
|
684
|
+
None
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
fn parse_file_decision(text: &str) -> Option<Value> {
|
|
688
|
+
parse_command_decision(text)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
fn parse_permission_response(text: &str, requested_permissions: &Value) -> Option<Value> {
|
|
692
|
+
let normalized = normalize_reply(text);
|
|
693
|
+
if normalized.contains("deny") || normalized.contains("decline") || normalized == "no" {
|
|
694
|
+
return Some(json!({
|
|
695
|
+
"scope": "turn",
|
|
696
|
+
"permissions": {},
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
if normalized.contains("allow")
|
|
700
|
+
|| normalized.contains("approve")
|
|
701
|
+
|| normalized.contains("accept")
|
|
702
|
+
|| normalized == "yes"
|
|
703
|
+
{
|
|
704
|
+
let scope = if normalized.contains("session") {
|
|
705
|
+
"session"
|
|
706
|
+
} else {
|
|
707
|
+
"turn"
|
|
708
|
+
};
|
|
709
|
+
return Some(json!({
|
|
710
|
+
"scope": scope,
|
|
711
|
+
"permissions": requested_permissions,
|
|
712
|
+
}));
|
|
713
|
+
}
|
|
714
|
+
None
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
fn parse_tool_user_input_response(text: &str, questions: &[Value]) -> Option<Value> {
|
|
718
|
+
if questions.is_empty() {
|
|
719
|
+
return Some(json!({ "answers": {} }));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if let Ok(value) = serde_json::from_str::<Value>(text) {
|
|
723
|
+
let answers_object = value.get("answers").unwrap_or(&value);
|
|
724
|
+
if let Some(answer_map) = answers_object.as_object() {
|
|
725
|
+
let mut answers = Map::new();
|
|
726
|
+
for question in questions {
|
|
727
|
+
let id = question.get("id").and_then(Value::as_str)?;
|
|
728
|
+
let answer_value = answer_map.get(id)?;
|
|
729
|
+
let answers_array = match answer_value {
|
|
730
|
+
Value::String(text) => vec![Value::String(text.clone())],
|
|
731
|
+
Value::Array(values) => values.clone(),
|
|
732
|
+
_ => return None,
|
|
733
|
+
};
|
|
734
|
+
answers.insert(id.to_string(), json!({ "answers": answers_array }));
|
|
735
|
+
}
|
|
736
|
+
return Some(json!({ "answers": answers }));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if questions.len() == 1 {
|
|
741
|
+
let id = questions[0].get("id").and_then(Value::as_str)?;
|
|
742
|
+
let answer = text.trim();
|
|
743
|
+
if answer.is_empty() {
|
|
744
|
+
return None;
|
|
745
|
+
}
|
|
746
|
+
return Some(json!({
|
|
747
|
+
"answers": {
|
|
748
|
+
id: {
|
|
749
|
+
"answers": [answer],
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
None
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
fn parse_mcp_elicitation_response(text: &str) -> Option<Value> {
|
|
759
|
+
let trimmed = text.trim();
|
|
760
|
+
if trimmed.is_empty() {
|
|
761
|
+
return None;
|
|
762
|
+
}
|
|
763
|
+
let normalized = normalize_reply(trimmed);
|
|
764
|
+
if normalized == "decline" || normalized == "deny" {
|
|
765
|
+
return Some(json!({ "action": "decline", "content": Value::Null }));
|
|
766
|
+
}
|
|
767
|
+
if normalized == "cancel" || normalized == "abort" {
|
|
768
|
+
return Some(json!({ "action": "cancel", "content": Value::Null }));
|
|
769
|
+
}
|
|
770
|
+
if normalized.starts_with("accept") {
|
|
771
|
+
let rest = trimmed["accept".len()..].trim();
|
|
772
|
+
let content = if rest.is_empty() {
|
|
773
|
+
Value::Null
|
|
774
|
+
} else if let Ok(json_value) = serde_json::from_str::<Value>(rest) {
|
|
775
|
+
json_value
|
|
776
|
+
} else {
|
|
777
|
+
Value::String(rest.to_string())
|
|
778
|
+
};
|
|
779
|
+
return Some(json!({ "action": "accept", "content": content }));
|
|
780
|
+
}
|
|
781
|
+
None
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
fn normalize_reply(text: &str) -> String {
|
|
785
|
+
text.trim().to_ascii_lowercase()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
fn new_chat_id() -> String {
|
|
789
|
+
Uuid::new_v4().simple().to_string()[..12].to_string()
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
fn new_pin() -> String {
|
|
793
|
+
let mut rng = rand::rng();
|
|
794
|
+
format!("{:06}", rng.random_range(0..=999_999))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#[cfg(test)]
|
|
798
|
+
mod tests {
|
|
799
|
+
use super::*;
|
|
800
|
+
|
|
801
|
+
#[test]
|
|
802
|
+
fn command_approval_reply_is_parsed() {
|
|
803
|
+
assert_eq!(parse_command_decision("approve"), Some(json!("accept")));
|
|
804
|
+
assert_eq!(
|
|
805
|
+
parse_command_decision("allow session"),
|
|
806
|
+
Some(json!("acceptForSession"))
|
|
807
|
+
);
|
|
808
|
+
assert_eq!(parse_command_decision("deny"), Some(json!("decline")));
|
|
809
|
+
assert_eq!(parse_command_decision("cancel"), Some(json!("cancel")));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#[test]
|
|
813
|
+
fn permission_reply_grants_requested_subset() {
|
|
814
|
+
let requested = json!({
|
|
815
|
+
"network": { "enabled": true },
|
|
816
|
+
"fileSystem": { "write": ["/tmp/demo"] }
|
|
817
|
+
});
|
|
818
|
+
assert_eq!(
|
|
819
|
+
parse_permission_response("allow session", &requested),
|
|
820
|
+
Some(json!({
|
|
821
|
+
"scope": "session",
|
|
822
|
+
"permissions": requested,
|
|
823
|
+
}))
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
#[test]
|
|
828
|
+
fn request_user_input_accepts_single_plain_text_reply() {
|
|
829
|
+
let questions = vec![json!({
|
|
830
|
+
"id": "workspace",
|
|
831
|
+
"question": "Which workspace should I use?",
|
|
832
|
+
})];
|
|
833
|
+
|
|
834
|
+
assert_eq!(
|
|
835
|
+
parse_tool_user_input_response("repo-a", &questions),
|
|
836
|
+
Some(json!({
|
|
837
|
+
"answers": {
|
|
838
|
+
"workspace": {
|
|
839
|
+
"answers": ["repo-a"]
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}))
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[test]
|
|
847
|
+
fn extract_request_text_filters_special_messages() {
|
|
848
|
+
assert_eq!(
|
|
849
|
+
extract_request_text(&Message {
|
|
850
|
+
id: "m1".to_string(),
|
|
851
|
+
sender_username: "jay".to_string(),
|
|
852
|
+
text: "[json-render]".to_string(),
|
|
853
|
+
}),
|
|
854
|
+
None
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|