Skip to content

Commit ea0e70e

Browse files
authored
examples/record-replay: fix commands broken by the safe-defaults sweep (#133)
1 parent 6ccbfb4 commit ea0e70e

9 files changed

Lines changed: 193 additions & 34 deletions

File tree

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ members = [
44
"crates/chidori-js",
55
"crates/test262-runner",
66
]
7+
# Bare `cargo run` / `cargo build` at the workspace root mean the CLI — the
8+
# form every README and doc uses (`cargo run -- run agents/my_agent.ts`).
9+
# Without this, the test262-runner binary makes `cargo run` ambiguous and it
10+
# refuses to run anything. CI's workspace-wide jobs pass `--workspace`
11+
# explicitly, so they are unaffected.
12+
default-members = ["crates/chidori"]
713
resolver = "2"
814

915
# Workspace-wide lint levels, inherited by every crate via `[lints]

crates/chidori/src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,10 +1253,14 @@ fn cmd_run_stream(
12531253
Ok(())
12541254
}
12551255
Err(e) => {
1256+
// Frames arrive in transpiled coordinates; the stream consumer
1257+
// sees the same original-TypeScript positions the CLI reporter
1258+
// shows. The returned error stays raw — report_cli_error remaps
1259+
// it once at its own display boundary.
12561260
let line = serde_json::json!({
12571261
"type": "done",
12581262
"status": "failed",
1259-
"error": format!("{e:#}"),
1263+
"error": crate::runtime::rust_engine::remap_stack_frames(&format!("{e:#}")),
12601264
});
12611265
println!("{line}");
12621266
Err(e)

crates/chidori/src/runtime/rust_engine.rs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,21 @@ pub(crate) fn set_display_project_root(root: PathBuf) {
214214
pub(crate) fn read_project_source(file: &str) -> Option<String> {
215215
let root = DISPLAY_PROJECT_ROOT
216216
.with(|r| r.borrow().clone())
217-
.or_else(|| std::env::current_dir().ok())?
218-
.canonicalize()
219-
.ok()?;
217+
.or_else(|| std::env::current_dir().ok())?;
218+
read_project_source_within(&root, file)
219+
}
220+
221+
/// As [`read_project_source`], but confined to an explicit `root` instead of
222+
/// the thread-local display root — for surfaces whose display boundary is not
223+
/// the CLI process's JS thread (the HTTP server's session errors). An empty
224+
/// root (the parent of a bare `agent.ts`) means the current directory.
225+
pub(crate) fn read_project_source_within(root: &Path, file: &str) -> Option<String> {
226+
let root = if root.as_os_str().is_empty() {
227+
Path::new(".")
228+
} else {
229+
root
230+
};
231+
let root = root.canonicalize().ok()?;
220232
let full = Path::new(file).canonicalize().ok()?;
221233
full.starts_with(&root)
222234
.then(|| std::fs::read_to_string(&full).ok())
@@ -283,6 +295,19 @@ pub(crate) fn parse_stack_frame(line: &str) -> Option<StackFrame<'_>> {
283295
/// uniform transpiled coordinates); error path only, so each distinct file
284296
/// re-runs the transpile pipeline once with map generation on.
285297
pub(crate) fn remap_stack_frames(err: &str) -> String {
298+
remap_stack_frames_via(err, read_project_source)
299+
}
300+
301+
/// As [`remap_stack_frames`], but confining frame source reads to an explicit
302+
/// project root — the server's variant, applied where a run error becomes a
303+
/// session's stored/returned `error` (the CLI resolves its root from the
304+
/// thread-local set at command startup; the server handlers run on tokio
305+
/// threads that never set it, so the agent's workspace root is passed in).
306+
pub(crate) fn remap_stack_frames_within(root: &Path, err: &str) -> String {
307+
remap_stack_frames_via(err, |file| read_project_source_within(root, file))
308+
}
309+
310+
fn remap_stack_frames_via(err: &str, read: impl Fn(&str) -> Option<String>) -> String {
286311
use std::collections::HashMap;
287312
let mut sources: HashMap<&str, Option<String>> = HashMap::new();
288313
let mut out = String::with_capacity(err.len());
@@ -294,7 +319,7 @@ pub(crate) fn remap_stack_frames(err: &str) -> String {
294319
let file = frame.file?;
295320
let source = sources
296321
.entry(file)
297-
.or_insert_with(|| read_project_source(file))
322+
.or_insert_with(|| read(file))
298323
.as_deref()?;
299324
let pos = crate::runtime::typescript::transpile::remap_to_original(
300325
Path::new(file),

crates/chidori/src/server.rs

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,8 @@ pub async fn serve(
676676
let acp_state = AcpState {
677677
store: state.session_store.clone(),
678678
run_prompt: Arc::new(move |inputs: Value| -> Result<Value, String> {
679-
run_agent_sync(&acp_runner_state, inputs).map_err(|e| e.to_string())
679+
run_agent_sync(&acp_runner_state, inputs)
680+
.map_err(|e| agent_error_string(&acp_runner_state.agent_path, &e))
680681
}),
681682
};
682683

@@ -1350,7 +1351,7 @@ async fn create_session(
13501351
};
13511352
match result {
13521353
Ok(run_result) => apply_run_outcome(&mut session, run_result),
1353-
Err(e) => session.error = Some(e.to_string()),
1354+
Err(e) => session.error = Some(agent_error_string(&state.agent_path, &e)),
13541355
}
13551356

13561357
if let Some(err) = store_or_500(&state, &session) {
@@ -1911,7 +1912,8 @@ async fn stream_session(
19111912
Err(e) => {
19121913
session.status = SessionStatus::Failed;
19131914
session.output = None;
1914-
session.error = Some(e.to_string());
1915+
session.error =
1916+
Some(agent_error_string(&state_for_stream.agent_path, &e));
19151917
}
19161918
}
19171919
if was_cancelled {
@@ -2107,6 +2109,18 @@ async fn cancel_session(
21072109
.into_response()
21082110
}
21092111

2112+
/// Render an agent-run error for a session's stored/returned `error` field.
2113+
/// Uncaught-exception stack frames arrive from the engine in transpiled
2114+
/// coordinates; remap them to positions in the original TypeScript against
2115+
/// the served agent's workspace root — the same display-boundary remap the
2116+
/// CLI applies in `main::report_cli_error` (which resolves its root from a
2117+
/// thread-local these tokio handlers never set). Frames that can't be read
2118+
/// or remapped pass through unchanged, as does any error without frames.
2119+
fn agent_error_string(agent_path: &FsPath, e: &anyhow::Error) -> String {
2120+
let root = crate::runtime::typescript::transpile::find_workspace_root(agent_path);
2121+
crate::runtime::rust_engine::remap_stack_frames_within(&root, &e.to_string())
2122+
}
2123+
21102124
/// Map a finished engine run onto a stored session: status, output, call log,
21112125
/// and the pending-pause fields (input prompt / signal listen set + timeout
21122126
/// deadline / approval). Shared by session creation, the durable resume tail,
@@ -2363,13 +2377,14 @@ async fn complete_pending_and_resume(
23632377
(StatusCode::OK, Json(session_view(&session))).into_response()
23642378
}
23652379
Err(e) => {
2380+
let error = agent_error_string(&state.agent_path, &e);
23662381
session.status = SessionStatus::Failed;
2367-
session.error = Some(e.to_string());
2382+
session.error = Some(error.clone());
23682383
let _ = state.session_store.put(&session);
23692384
state.warm_runs.lock().unwrap().remove(&session.id);
23702385
(
23712386
StatusCode::INTERNAL_SERVER_ERROR,
2372-
Json(json!({"error": e.to_string()})),
2387+
Json(json!({"error": error})),
23732388
)
23742389
.into_response()
23752390
}
@@ -2447,7 +2462,7 @@ async fn resume_session(
24472462
Some(Ok(run_result)) => apply_run_outcome(&mut session, run_result),
24482463
Some(Err(e)) => {
24492464
session.status = SessionStatus::Failed;
2450-
session.error = Some(e.to_string());
2465+
session.error = Some(agent_error_string(&state.agent_path, &e));
24512466
}
24522467
None => {
24532468
session.status = SessionStatus::Failed;
@@ -2927,12 +2942,13 @@ async fn approve_session(
29272942
(StatusCode::OK, Json(session_view(&session))).into_response()
29282943
}
29292944
Err(e) => {
2945+
let error = agent_error_string(&state.agent_path, &e);
29302946
session.status = SessionStatus::Failed;
2931-
session.error = Some(e.to_string());
2947+
session.error = Some(error.clone());
29322948
let _ = state.session_store.put(&session);
29332949
(
29342950
StatusCode::INTERNAL_SERVER_ERROR,
2935-
Json(json!({"error": e.to_string()})),
2951+
Json(json!({"error": error})),
29362952
)
29372953
.into_response()
29382954
}
@@ -3012,7 +3028,7 @@ async fn handle_event(
30123028
}
30133029
Err(e) => {
30143030
eprintln!("Agent error: {e:#}");
3015-
let error = json!({"error": e.to_string()});
3031+
let error = json!({"error": agent_error_string(&state.agent_path, &e)});
30163032
(StatusCode::INTERNAL_SERVER_ERROR, Json(error)).into_response()
30173033
}
30183034
}
@@ -3026,6 +3042,73 @@ mod tests {
30263042
};
30273043
use axum::body;
30283044

3045+
/// A failed session's `error` must carry stack frames in ORIGINAL
3046+
/// TypeScript coordinates for every frame, not just the throwing one —
3047+
/// the engine hands the server transpiled-bundle positions, and the
3048+
/// server (whose tokio handlers never set the CLI's thread-local display
3049+
/// root) remaps them against the agent's workspace root.
3050+
#[test]
3051+
fn agent_error_string_remaps_every_frame_to_original_source() {
3052+
let dir = std::env::temp_dir().join(format!("chidori-srv-frames-{}", uuid::Uuid::new_v4()));
3053+
std::fs::create_dir_all(&dir).unwrap();
3054+
let agent_path = dir.join("agent.ts");
3055+
// The interface block exists only in the original TypeScript, so every
3056+
// transpiled line number below it disagrees with the original.
3057+
let src = "interface Row {\n\
3058+
\x20 id: string;\n\
3059+
}\n\
3060+
function inner(row: Row): never {\n\
3061+
\x20 throw new Error(\"bad \" + row.id);\n\
3062+
}\n\
3063+
function outer(): never {\n\
3064+
\x20 return inner({ id: \"x\" });\n\
3065+
}\n\
3066+
export async function agent() { return outer(); }\n";
3067+
std::fs::write(&agent_path, src).unwrap();
3068+
3069+
// Derive the frames' transpiled coordinates the same way the engine
3070+
// stamps them: the emitted definition line of each function.
3071+
let (js, _map) =
3072+
crate::runtime::typescript::transpile::transpile_source_with_map(&agent_path, src)
3073+
.unwrap();
3074+
let emitted_line = |name: &str| {
3075+
js.lines()
3076+
.position(|l| l.contains(&format!("function {name}")))
3077+
.map(|i| i as u32 + 1)
3078+
.unwrap()
3079+
};
3080+
let (inner_line, outer_line) = (emitted_line("inner"), emitted_line("outer"));
3081+
// The interface strip is what makes this test meaningful: emitted
3082+
// positions must disagree with the original definition lines (4, 7).
3083+
assert_ne!(
3084+
inner_line, 4,
3085+
"transpile no longer shifts lines; rewrite this test"
3086+
);
3087+
3088+
let path_str = agent_path.to_string_lossy();
3089+
let err = anyhow::anyhow!(
3090+
"JavaScript exception: Error: bad x\n at inner ({path_str}:{inner_line}:10)\n at outer ({path_str}:{outer_line}:10)"
3091+
);
3092+
let remapped = agent_error_string(&agent_path, &err);
3093+
assert!(
3094+
remapped.contains(&format!("at inner ({path_str}:4:")),
3095+
"throwing frame lands on the original definition line: {remapped}"
3096+
);
3097+
assert!(
3098+
remapped.contains(&format!("at outer ({path_str}:7:")),
3099+
"the frame ABOVE the throwing one lands on its original line too: {remapped}"
3100+
);
3101+
3102+
// Errors without frames pass through byte-identical.
3103+
let plain = anyhow::anyhow!("policy: `tool:x` denied");
3104+
assert_eq!(
3105+
agent_error_string(&agent_path, &plain),
3106+
"policy: `tool:x` denied"
3107+
);
3108+
3109+
let _ = std::fs::remove_dir_all(dir);
3110+
}
3111+
30293112
#[test]
30303113
fn bearer_token_matches_single_key() {
30313114
assert!(bearer_token_matches("Bearer sekrit", "sekrit"));

examples/interactive-pipeline/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,18 @@ examples/interactive-pipeline/run.sh
3535

3636
# …or directly
3737
chidori run examples/interactive-pipeline/interactive_pipeline.ts \
38-
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}'
38+
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}' --trusted
3939

4040
# …or from source
4141
cargo run -- run examples/interactive-pipeline/interactive_pipeline.ts \
42-
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}'
42+
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}' --trusted
4343
```
4444

45+
(`--trusted`: the per-stage `review_batch` tool call is a gated effect, and
46+
`chidori run` is ask-by-default — without the flag each stage stops for an
47+
extra y/N approval before its checkpoint. This is in-repo code you're running
48+
on yourself; see [`docs/running-modes.md`](../../docs/running-modes.md).)
49+
4550
At each checkpoint the agent prints a prompt to your terminal and **blocks on
4651
stdin** (`chidori.input`). Type one of:
4752

@@ -62,7 +67,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
6267
export OTEL_SERVICE_NAME=interactive-pipeline # optional (defaults to "chidori")
6368

6469
chidori run examples/interactive-pipeline/interactive_pipeline.ts \
65-
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}'
70+
-i '{"pipeline":"triage","stages":5,"itemsPerStage":4}' --trusted
6671
```
6772

6873
`run.sh` does this for you, but only when tael is actually listening on `:4317`.

examples/interactive-pipeline/run.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,8 @@ else
2929
echo " (start tael, then re-run; or set OTEL_EXPORTER_OTLP_ENDPOINT yourself)" >&2
3030
fi
3131

32+
# --trusted: the per-stage review_batch tool call is a gated effect, and
33+
# `chidori run` asks before gated effects by default — the flag keeps the
34+
# session's only prompts the agent's own checkpoints.
3235
exec cargo run --quiet --manifest-path "$REPO/Cargo.toml" \
33-
-- run "$AGENT" -i "$INPUT"
36+
-- run "$AGENT" -i "$INPUT" --trusted

0 commit comments

Comments
 (0)