Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "deixic-code",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "5c0ef43f86d41311f868236ebbc050f14969e263",
"sourceSha": "fac55f7217fbc6e14c155ecfc00aed5c8c31adca",
"destinationRepository": "dx-corp/code",
"priorProjectedBase": "931b3d60176d650c886e2ec3802031569808b470",
"priorProjectedBase": "e9728f9fd83065904e209c56238a89291741bd74",
"definitionDigest": "82936441c776e3e8edb5d215a75007ec9714a233f489d460075d79d5ef5ba32f",
"toolDigest": "c244d99199a7ae3eb8ff644a99462163c23b0bb6a83ef50af01efbdca0b81d04",
"contentDigest": "11f79bfdda6fbb50a86620b4528c84bbabc195dc16b6c97058a812bcfadeb395",
"contentDigest": "40c32f0bd494617b1e7297f7daf817ddd6861452f132690578f73452179f2900",
"publicationEligible": true
}
18 changes: 18 additions & 0 deletions vendor/dex-loop/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,24 @@ impl Context {
};
}
}
Event::AutoApproved {
call,
approval,
args_digest,
..
} => {
// The receipt is the decision: the call is dispatchable at
// once, and a replay adopts the same digest the engine bound.
if let Some(state) = self.state_mut(call) {
*state = CallState::Parked {
approval: approval.clone(),
decision: Some(Decision {
approved: true,
args_digest: args_digest.clone(),
}),
};
}
}
Event::Question { call, .. } => {
if let Some(state) = self.state_mut(call) {
*state = CallState::Asked { answer: None };
Expand Down
94 changes: 42 additions & 52 deletions vendor/dex-loop/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//!
//! Per step: `StepStarted` → model stream (text to the log as it arrives) →
//! `ModelStepCompleted` (the commit point: every proposed call, with full
//! arguments) → per call, in order: policy → approval → `ToolStarted` →
//! effect → `ToolFinished`. The turn ends when a step proposes no calls.
//! arguments) → per call, in order: policy → (auto-approval receipt) →
//! `ToolStarted` → effect → `ToolFinished`. The turn ends when a step
//! proposes no calls. No call ever parks for a human: a `NeedsApproval`
//! verdict is granted at once and recorded as `AutoApproved`.

use std::pin::pin;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
Expand All @@ -16,8 +18,8 @@
use crate::compaction::{Compactor, NoCompaction};
use crate::context::{CallState, Context, Decision, Status};
use crate::event::{
ApprovalId, ApprovalMode, CallId, ErrorCode, Event, HEADLESS_AUTO_APPROVER, Outcome,
PrincipalId, ProposedCall, ToolName, ToolResult, TurnId,
AUTO_APPROVER, ApprovalId, CallId, ErrorCode, Event, Outcome, PrincipalId, ProposedCall,
ToolName, ToolResult, TurnId,
};
use crate::ports::{
Claim, Effects, ExecutorKind, Fenced, GovernanceClass, Log, Model, ModelChunk, ModelError,
Expand Down Expand Up @@ -71,7 +73,9 @@
pub enum Exit {
/// The model answered without tool calls.
Done,
/// Waiting for `Event::ApprovalDecided`; call `run` again after it lands.
/// Legacy: waiting for `Event::ApprovalDecided`. The engine no longer
/// returns it (no call parks for a human); hosts keep the arm so an
/// older binary's exit still matches.
Parked(ApprovalId),
/// Waiting for `Event::Answer` to this call; call `run` again after it lands.
Asked(CallId),
Expand Down Expand Up @@ -538,13 +542,20 @@
approval,
decision: None,
}) => {
// A call an older deploy parked for a human and nobody
// decided. No human decides any more: grant it now,
// under the same approval id, so the thread resumes
// instead of waiting forever.
if self
.flush(ctx, &calls, &mut wave, cancel, run_started)
.await?
{
break;
}
return Ok(Some(Exit::Parked(approval)));
let summary = self
.offered_spec(ctx, &call.tool)
.map_or_else(|| call.tool.to_string(), |spec| spec.label);
Some(self.auto_approve(ctx, call, approval, summary).await?)
}
Some(CallState::Parked {
decision: Some(decision),
Expand Down Expand Up @@ -641,19 +652,17 @@
if self.refuse_uncertain_repeat(ctx, call, &spec).await? {
continue;
}
// Policy asked for approval: no human is asked. The call is
// granted at once and the receipt goes to the log before the
// effect; the pending wave runs first so effects keep order.
if let (None, Verdict::NeedsApproval { approval, summary }) = (decision, verdict) {

Check failure on line 658 in vendor/dex-loop/src/engine.rs

View workflow job for this annotation

GitHub Actions / unresolved-review-threads / unresolved-review-threads

unresolved P1 review thread

Check failure on line 658 in vendor/dex-loop/src/engine.rs

View workflow job for this annotation

GitHub Actions / unresolved-review-threads / unresolved-review-threads

unresolved P1 review thread

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update legacy approval-mode scenarios

This makes every NeedsApproval verdict emit AutoApproved, including ApprovalMode::Interactive, but the unchanged scenarios integration tests still require the old behavior: headless_turn_auto_approves_an_ask_gated_tool_and_records_the_audit_pair expects ApprovalRequested plus ApprovalDecided, interactive_turn_still_parks_on_the_same_ask_gated_tool expects Exit::Parked, and the client-tool case expects the same old pair. Those assertions now fail whenever cargo test --test scenarios can run; update or remove the obsolete mode-specific expectations along with this behavior change.

Useful? React with 👍 / 👎.

if self
.flush(ctx, &calls, &mut wave, cancel, run_started)
.await?
{
break;
}
if !self
.request_approval(ctx, call, approval.clone(), summary)
.await?
{
return Ok(Some(Exit::Parked(approval)));
}
self.auto_approve(ctx, call, approval, summary).await?;
}

if spec.executor == ExecutorKind::User {
Expand Down Expand Up @@ -755,12 +764,7 @@
}
let approval = ApprovalId::new(format!("client-{}", call.id));
let summary = format!("Run {} in your browser", spec.label);
if !self
.request_approval(ctx, call, approval.clone(), summary)
.await?
{
return Ok(ClientToolOutcome::Exit(Exit::Parked(approval)));
}
self.auto_approve(ctx, call, approval, summary).await?;
}
if self.flush(ctx, calls, wave, cancel, run_started).await? {
return Ok(ClientToolOutcome::Break);
Expand Down Expand Up @@ -1119,48 +1123,34 @@
}
}

/// Appends and observes.
/// Logs an `ApprovalRequested` for a call policy said must ask. Returns
/// `true` when the call is already decided and dispatch continues.
///
/// An `Interactive` turn parks (returns `false`) until a human's
/// `ApprovalDecided` arrives. A `Headless` turn has no human, so the
/// request and an approving `ApprovalDecided` from
/// `HEADLESS_AUTO_APPROVER` are appended together: the audit trail is the
/// same pair a human approval would leave. Only reached for a
/// `NeedsApproval` verdict; `Deny` was handled before this point and stays
/// denied.
async fn request_approval(
/// Grants a call policy said must ask, at once, and writes the receipt.
/// No human is ever asked: the `AutoApproved` row (call, approval id,
/// argument digest, summary, `AUTO_APPROVER`) is the durable record of
/// what ran, and a replay adopts it instead of asking policy to grant
/// again. Only reached for a `NeedsApproval` verdict or a legacy parked
/// call; `Deny` was handled before this point and stays denied.
async fn auto_approve(
&self,
ctx: &mut Context,
call: &ProposedCall,
approval: ApprovalId,
summary: String,
) -> Result<bool, Fenced> {
let mut events = vec![Event::ApprovalRequested {
call: call.id.clone(),
approval: approval.clone(),
args_digest: call.args_digest.clone(),
summary,
}];
let headless = ctx.approval_mode() == ApprovalMode::Headless;
if headless {
events.push(Event::ApprovalDecided {
) -> Result<Decision, Fenced> {
self.emit(
ctx,
vec![Event::AutoApproved {
call: call.id.clone(),
approval,
args_digest: call.args_digest.clone(),
approved: true,
principal: PrincipalId::new(HEADLESS_AUTO_APPROVER),
});
}
// The engine wrote the decision itself: it must not move the control
// cursor past a `Steer` or `Interrupt` that landed in between.
let control = ctx.control_cursor();
self.emit(ctx, events).await?;
if headless {
ctx.rewind_control(control);
}
Ok(headless)
summary,
principal: PrincipalId::new(AUTO_APPROVER),
}],
)
.await?;
Ok(Decision {
approved: true,
args_digest: call.args_digest.clone(),
})
}

async fn emit(&self, ctx: &mut Context, events: Vec<Event>) -> Result<(), Fenced> {
Expand Down
26 changes: 24 additions & 2 deletions vendor/dex-loop/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,16 @@ impl ApprovalMode {
}

/// The principal recorded on an `ApprovalDecided` the engine wrote itself
/// for a `Headless` turn.
/// for a `Headless` turn. Kept so stored rows still decode; the engine no
/// longer writes it (see `AUTO_APPROVER`).
pub const HEADLESS_AUTO_APPROVER: &str = "policy:headless_auto_approve";

/// The principal recorded on every `AutoApproved` receipt. No human approves
/// a Dex tool call: a `NeedsApproval` verdict is granted by policy at once,
/// on every surface and for every principal, and the receipt is the audit
/// record of what ran, for whom, and under which argument digest.
pub const AUTO_APPROVER: &str = "policy:auto_approve";

/// One row in a thread's log. Hosts append the ingress events (`UserMessage`,
/// `Steer`, `Interrupt`, `ApprovalDecided`, `Answer`, and optionally
/// `ToolProgress`); the engine appends everything else.
Expand Down Expand Up @@ -464,13 +471,28 @@ pub enum Event {
output: Output,
receipt: Option<ReceiptId>,
},
/// The turn is parked until an `ApprovalDecided` for this call arrives.
/// Legacy: the turn was parked until an `ApprovalDecided` for this call
/// arrived. Never emitted any more (policy grants at once and writes
/// `AutoApproved`); still decoded from stored history. A call left in
/// this state by an older deploy is auto-approved on its next run.
ApprovalRequested {
call: CallId,
approval: ApprovalId,
args_digest: String,
summary: String,
},
/// The durable receipt for a call policy would once have parked for a
/// human: granted at once by `AUTO_APPROVER`, never shown as a prompt.
/// `summary` is what the approver would have read (a guardian flag is
/// carried here too); `args_digest` binds the receipt to the exact
/// arguments that ran. Not a control event: the engine writes it itself.
AutoApproved {
call: CallId,
approval: ApprovalId,
args_digest: String,
summary: String,
principal: PrincipalId,
},
/// The turn is parked until an `Answer` for this call arrives.
Question {
call: CallId,
Expand Down
7 changes: 4 additions & 3 deletions vendor/dex-loop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ pub use compaction::{Compaction, Compactor, NoCompaction, Summarize, Threshold};
pub use context::{Context, Entry, Message};
pub use engine::{CUT_OFF_NOTICE, DEFAULT_TOOL_CALL_DEADLINE, Engine, Exit, TOOLS_SEARCH};
pub use event::{
ApprovalId, ApprovalMode, ArtifactRef, CallId, ClientToolSpec, Cursor, ErrorCode, Event,
HEADLESS_AUTO_APPROVER, MessageId, Outcome, Output, OutputRef, PrincipalId, ProposedCall,
ProviderReasoning, ReceiptId, ThreadId, ToolName, ToolResult, TurnId, Usage, args_digest,
AUTO_APPROVER, ApprovalId, ApprovalMode, ArtifactRef, CallId, ClientToolSpec, Cursor,
ErrorCode, Event, HEADLESS_AUTO_APPROVER, MessageId, Outcome, Output, OutputRef, PrincipalId,
ProposedCall, ProviderReasoning, ReceiptId, ThreadId, ToolName, ToolResult, TurnId, Usage,
args_digest,
};
pub use ports::{
Claim, Effects, ExecutorKind, Fenced, GovernanceClass, Log, Model, ModelChunk, ModelError,
Expand Down
Loading
Loading