Give the interactive session the `delegate` tool it was missing

5f38ca711932 · AtlantisPleb · · parent f00c28ebbeca

Give the interactive session the `delegate` tool it was missing

Both entry points in `interactive.rs` built their registry with
`HarnessToolRegistry::new(None)`, which carries no delegation gate. So
`oa coder --headless` could start children and the session a person actually
sits in could not.

The symptom is the kind that hides: a missing tool looks exactly like a model
choosing not to call one. Nothing errors, nothing is logged, and the reply just
never delegates.

Both paths now share one `session_tools`, so the two cannot drift apart again,
and the gate carries this session's lane and credential rather than the
defaults. Children still get no `delegate` themselves, so recursive fan-out
stays impossible.

Two tests: one that the interactive registry lists `delegate`, one that the
gate carries the session's own lane and token rather than a default. The second
matters because a gate naming the wrong lane would start children somewhere
other than where the session runs, which is worse than having no gate.

Refs openagents#71, openagents#84.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/interactive.rs

Diff

1 file changed, +61 -4

crates/openagents-cli/src/interactive.rs modified +61 -4

@@ -21,7 +21,7 @@

21 21
use crate::cli::CoderArgs;
22 22
use crate::composer::{Composer, ComposerAction};
23 23
use crate::runtime::{CoderRuntimeSession, Lane};
24
use crate::tools::HarnessToolRegistry;
24
use crate::tools::{DelegationGate, HarnessToolRegistry};
25 25
use crate::tui::{composer_text_width, BoxFrame, ChromeView, Entry, Role};
26 26
27 27
use crossterm::{

@@ -395,17 +395,37 @@ fn install_panic_hook() {

395 395
    });
396 396
}
397 397
398
/// The tools a session a person sits in gets.
399
///
400
/// An interactive session may start children on the same terms a headless one
401
/// does: same lane, same credential, and the children do not get the tool
402
/// themselves. This lives in one function so both entry points here share it —
403
/// they each built the registry separately before, both passed `None`, and the
404
/// result was that `delegate` worked headless and was missing from the only
405
/// session anyone actually types into.
406
fn session_tools(lane_name: &str, token: &Option<String>) -> HarnessToolRegistry {
407
    HarnessToolRegistry::with_delegation(
408
        None,
409
        DelegationGate {
410
            lane: lane_name.to_string(),
411
            user_token: token.clone(),
412
            max_count: crate::delegate::MAX_DELEGATE_COUNT,
413
        },
414
    )
415
}
416
398 417
pub async fn run_tui(
399 418
    args: CoderArgs,
400 419
    token: Option<String>,
401 420
) -> Result<(), Box<dyn std::error::Error>> {
402
    let lane = Lane::from_str(&args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string()));
421
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
422
    let lane = Lane::from_str(&lane_name);
403 423
404 424
    if !is_terminal() {
405 425
        return run_without_a_terminal(args, token, lane).await;
406 426
    }
407 427
408
    let tools = HarnessToolRegistry::new(None);
428
    let tools = session_tools(&lane_name, &token);
409 429
    let session = CoderRuntimeSession::new(lane.clone(), None, token, tools);
410 430
411 431
    let (control_tx, control_rx) = unbounded_channel::<Control>();

@@ -454,6 +474,7 @@ async fn run_without_a_terminal(

454 474
    token: Option<String>,
455 475
    lane: Lane,
456 476
) -> Result<(), Box<dyn std::error::Error>> {
477
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
457 478
    let Some(prompt) = args.prompt else {
458 479
        eprintln!(
459 480
            "`oa coder` needs a terminal for an interactive session. \

@@ -462,7 +483,7 @@ async fn run_without_a_terminal(

462 483
        return Ok(());
463 484
    };
464 485
465
    let tools = HarnessToolRegistry::new(None);
486
    let tools = session_tools(&lane_name, &token);
466 487
    let mut session = CoderRuntimeSession::new(lane, None, token, tools);
467 488
    // The reply is printed as it streams. `execute_turn` also returns the last
468 489
    // step's text, which is the same text — so it is printed only when nothing

@@ -488,3 +509,39 @@ async fn run_without_a_terminal(

488 509
fn is_terminal() -> bool {
489 510
    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
490 511
}
512
513
#[cfg(test)]
514
mod tests {
515
    use super::*;
516
517
    /// Both entry points in this file once built their own registry with
518
    /// `HarnessToolRegistry::new(None)`, which carries no delegation gate. The
519
    /// symptom was subtle: `oa coder --headless` could start children and the
520
    /// interactive session silently could not, because a missing tool looks
521
    /// exactly like a model choosing not to call it.
522
    #[test]
523
    fn an_interactive_session_can_delegate() {
524
        let tools = session_tools("ox-alpha", &Some("token".to_string()));
525
        let names: Vec<String> = tools.list_tools().into_iter().map(|t| t.name).collect();
526
        assert!(
527
            names.iter().any(|n| n == "delegate"),
528
            "an interactive session got no `delegate` tool; it has {:?}",
529
            names
530
        );
531
    }
532
533
    /// The gate carries the lane and credential children spend against. A gate
534
    /// that exists but names the wrong lane would start children on the default
535
    /// lane while the session runs on another, which is worse than no gate.
536
    #[test]
537
    fn the_gate_carries_this_sessions_lane_and_credential() {
538
        let tools = session_tools("claude", &Some("secret-token".to_string()));
539
        let gate = tools
540
            .delegation
541
            .as_ref()
542
            .expect("an interactive session must carry a delegation gate");
543
        assert_eq!(gate.lane, "claude");
544
        assert_eq!(gate.user_token.as_deref(), Some("secret-token"));
545
        assert_eq!(gate.max_count, crate::delegate::MAX_DELEGATE_COUNT);
546
    }
547
}

This page updates live while a promote is in flight · changelog