feat(TASK-035): wire turn/session/compression seams + top-level agent bracket
Turn bracket (repl::TurnBracket): turn.started on entry and exactly one of
turn.{completed,interrupted,failed} on exit, with the interrupted-vs-failed
discrimination (abort_signal.aborted_ctrlc()) in one place. Wired at the
three outer boundaries: ask(), start_directive, and shell_execute — never at
the per-LLM-round before/after_chat_completion seams.
Session seams: session.started fires for newly created sessions only
(use_session's new_from_ctx paths; resume fires nothing), session.ended in
exit_session while the session is still attached, session.compressed on the
success path of both compress_session call sites (auto-compression and the
.compress command).
Session-held role hooks: Session snapshots the role's HooksMap on set_role,
clears it on clear_role/sync_agent, and re-reads it from the role file on
load; resolved_hooks gains a session leg so role hooks keep firing when the
role lives in the session (fresh set_role and resumed sessions alike).
Top-level agent bracket: agent.started/completed/failed once per agent RUN —
headless --agent dispatch and the interactive agent-context lifetime (enter
via .agent or startup; exit via switch-away or REPL teardown), never per
message. COYOTE_AGENT_ID is synthesized as top-<agent-name>-<nonce>.
Exit-path seams call hooks::drain_pending with a 2s bound so exit hooks
finish spawning before process teardown; interactive mid-session events
never drain.
This commit is contained in:
5 files changed
+778
-32
No files matched your search
+58
-24
@@ -23,7 +23,8 @@ extern crate log;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::client::{
|
||||
ModelType, call_chat_completions, call_chat_completions_streaming, catalog, list_models, oauth,
|
||||
Client, ModelType, call_chat_completions, call_chat_completions_streaming, catalog,
|
||||
list_models, oauth,
|
||||
};
|
||||
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
|
||||
use crate::config::{
|
||||
@@ -36,7 +37,7 @@ use crate::config::{memory, paths};
|
||||
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
|
||||
use crate::mcp::McpServersConfig;
|
||||
use crate::render::{prompt_theme, render_error};
|
||||
use crate::repl::Repl;
|
||||
use crate::repl::{EXIT_HOOK_DRAIN_TIMEOUT, Repl, TurnBracket};
|
||||
use crate::utils::*;
|
||||
use crate::vault::{Vault, interpolate_secrets};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
@@ -387,6 +388,7 @@ async fn run(
|
||||
let app = Arc::clone(&ctx.app.config);
|
||||
ctx.use_agent(app.as_ref(), agent, session, abort_signal.clone())
|
||||
.await?;
|
||||
ctx.top_level_agent_started();
|
||||
} else {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
if let Some(prompt) = &cli.temp_role {
|
||||
@@ -523,9 +525,14 @@ async fn run(
|
||||
return Ok(());
|
||||
}
|
||||
if cli.execute && !is_repl {
|
||||
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await?;
|
||||
return Ok(());
|
||||
let result = async {
|
||||
let input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||
shell_execute(&mut ctx, &SHELL, input, abort_signal.clone()).await
|
||||
}
|
||||
.await;
|
||||
ctx.top_level_agent_finished(result.as_ref().err());
|
||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||
return result;
|
||||
}
|
||||
|
||||
{
|
||||
@@ -545,9 +552,15 @@ async fn run(
|
||||
|
||||
match is_repl {
|
||||
false => {
|
||||
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||
input.use_embeddings(abort_signal.clone()).await?;
|
||||
start_directive(&mut ctx, input, cli.code, abort_signal).await
|
||||
let result = async {
|
||||
let mut input = create_input(&ctx, text, &cli.file, abort_signal.clone()).await?;
|
||||
input.use_embeddings(abort_signal.clone()).await?;
|
||||
start_directive(&mut ctx, input, cli.code, abort_signal.clone()).await
|
||||
}
|
||||
.await;
|
||||
ctx.top_level_agent_finished(result.as_ref().err());
|
||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||
result
|
||||
}
|
||||
true => {
|
||||
if !*IS_STDOUT_TERMINAL {
|
||||
@@ -564,6 +577,17 @@ async fn start_directive(
|
||||
input: Input,
|
||||
code_mode: bool,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
let bracket = TurnBracket::enter(ctx);
|
||||
let result = start_directive_inner(ctx, input, code_mode, abort_signal.clone()).await;
|
||||
bracket.finish(ctx, &abort_signal, result)
|
||||
}
|
||||
|
||||
async fn start_directive_inner(
|
||||
ctx: &mut RequestContext,
|
||||
input: Input,
|
||||
code_mode: bool,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
ctx.session_abort = Some(abort_signal.clone());
|
||||
@@ -639,22 +663,10 @@ async fn shell_execute(
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<()> {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
let client = input.create_client()?;
|
||||
ctx.before_chat_completion(&input)?;
|
||||
let (eval_str, _) = call_chat_completions(
|
||||
&input,
|
||||
false,
|
||||
true,
|
||||
client.as_ref(),
|
||||
ctx,
|
||||
abort_signal.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
ctx.after_chat_completion(app.as_ref(), &input, &eval_str, &[])?;
|
||||
if eval_str.is_empty() {
|
||||
bail!("No command generated");
|
||||
}
|
||||
// Each revision round is its own turn: the recursive 'r' call re-enters.
|
||||
let bracket = TurnBracket::enter(ctx);
|
||||
let result = shell_execute_turn(ctx, &input, abort_signal.clone()).await;
|
||||
let (client, eval_str) = bracket.finish(ctx, &abort_signal, result)?;
|
||||
if app.dry_run {
|
||||
app.print_markdown(&eval_str)?;
|
||||
return Ok(());
|
||||
@@ -680,6 +692,7 @@ async fn shell_execute(
|
||||
if code == 0 && app.save_shell_history {
|
||||
let _ = append_to_shell_history(&shell.name, &eval_str, code);
|
||||
}
|
||||
hooks::drain_pending(EXIT_HOOK_DRAIN_TIMEOUT).await;
|
||||
process::exit(code);
|
||||
}
|
||||
'r' => {
|
||||
@@ -727,6 +740,27 @@ async fn shell_execute(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The LLM portion of one shell-execute turn — everything the turn bracket
|
||||
/// measures. The interactive follow-up menu (execute/revise/describe/copy)
|
||||
/// runs after the turn has already completed.
|
||||
async fn shell_execute_turn(
|
||||
ctx: &mut RequestContext,
|
||||
input: &Input,
|
||||
abort_signal: AbortSignal,
|
||||
) -> Result<(Box<dyn Client>, String)> {
|
||||
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
|
||||
let client = input.create_client()?;
|
||||
ctx.before_chat_completion(input)?;
|
||||
let (eval_str, _) =
|
||||
call_chat_completions(input, false, true, client.as_ref(), ctx, abort_signal).await?;
|
||||
|
||||
ctx.after_chat_completion(app.as_ref(), input, &eval_str, &[])?;
|
||||
if eval_str.is_empty() {
|
||||
bail!("No command generated");
|
||||
}
|
||||
Ok((client, eval_str))
|
||||
}
|
||||
|
||||
async fn create_input(
|
||||
ctx: &RequestContext,
|
||||
text: Option<String>,
|
||||
|
||||
Reference in new issue
Block a user