feat: expose worker model and context metadata
This commit is contained in:
@@ -766,6 +766,10 @@ pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
pub session: SessionSnapshot,
|
||||
/// Public execution metadata for this child. Older snapshots and service
|
||||
/// workers may omit it; clients must never substitute the parent's values.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub greeting: Option<Greeting>,
|
||||
#[serde(default)]
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -1667,11 +1671,45 @@ impl InFlightToolCallState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker self-description rendered by the TUI when a session starts empty.
|
||||
/// Public reasoning configuration resolved for the model client.
|
||||
///
|
||||
/// Built once in the Worker controller from the resolved manifest and
|
||||
/// transmitted alongside `Event::Snapshot` so clients don't need
|
||||
/// their own view of the manifest.
|
||||
/// This deliberately exposes only the effective provider control, not the
|
||||
/// Worker manifest or provider credentials. The tagged shape prevents token
|
||||
/// budgets from being presented as effort labels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ReasoningConfig {
|
||||
Effort { effort: String },
|
||||
BudgetTokens { budget_tokens: i32 },
|
||||
}
|
||||
|
||||
/// Provenance of the current-context token count in a [`Greeting`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextTokenSource {
|
||||
/// Provider-reported input occupancy from the most recent LLM request.
|
||||
Measured,
|
||||
/// Current history projected from the Worker's token accounting records.
|
||||
Estimated,
|
||||
}
|
||||
|
||||
/// Current prompt-context occupancy, distinct from cumulative run traffic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct ContextUsage {
|
||||
pub tokens: u64,
|
||||
pub source: ContextTokenSource,
|
||||
}
|
||||
|
||||
/// Worker self-description rendered by clients when a session starts empty.
|
||||
///
|
||||
/// Snapshot metadata contains only resolved public execution information so
|
||||
/// clients do not need their own copy of the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -1680,14 +1718,22 @@ pub struct Greeting {
|
||||
pub cwd: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
/// Effective reasoning control. Absent means unspecified or unavailable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
pub scope_summary: String,
|
||||
pub tools: Vec<String>,
|
||||
/// Model context window in tokens. Always filled by the Worker greeting.
|
||||
/// Resolved effective model context window in tokens. Legacy producers use
|
||||
/// zero when this is unavailable; clients must treat that as unknown.
|
||||
#[serde(default)]
|
||||
pub context_window: u64,
|
||||
/// Estimated current session context tokens at connect time.
|
||||
/// Legacy numeric mirror retained for mixed-version clients.
|
||||
#[serde(default)]
|
||||
pub context_tokens: u64,
|
||||
/// Typed current-context value. Older snapshots omit it; clients may fall
|
||||
/// back to `context_tokens` only when `context_window` is known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_usage: Option<ContextUsage>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2354,6 +2400,11 @@ mod tests {
|
||||
tools: vec!["Read".into()],
|
||||
context_window: 200_000,
|
||||
context_tokens: 42_000,
|
||||
reasoning: Some(ReasoningConfig::BudgetTokens { budget_tokens: -1 }),
|
||||
context_usage: Some(ContextUsage {
|
||||
tokens: 42_000,
|
||||
source: ContextTokenSource::Measured,
|
||||
}),
|
||||
},
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
@@ -2372,6 +2423,19 @@ mod tests {
|
||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["reasoning"]["kind"],
|
||||
"budget_tokens"
|
||||
);
|
||||
assert_eq!(parsed["data"]["greeting"]["reasoning"]["budget_tokens"], -1);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["context_usage"]["tokens"],
|
||||
42_000
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["data"]["greeting"]["context_usage"]["source"],
|
||||
"measured"
|
||||
);
|
||||
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
|
||||
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
|
||||
}
|
||||
@@ -2381,7 +2445,17 @@ mod tests {
|
||||
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
|
||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
Event::Snapshot {
|
||||
greeting,
|
||||
in_flight,
|
||||
..
|
||||
} => {
|
||||
assert!(in_flight.is_empty());
|
||||
assert_eq!(greeting.reasoning, None);
|
||||
assert_eq!(greeting.context_usage, None);
|
||||
assert_eq!(greeting.context_window, 0);
|
||||
assert_eq!(greeting.context_tokens, 0);
|
||||
}
|
||||
other => panic!("expected Snapshot, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -2399,6 +2473,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Running.into(),
|
||||
in_flight: InFlightSnapshot {
|
||||
|
||||
@@ -6,17 +6,18 @@ use ts_rs::{Config, TS};
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||
RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
|
||||
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
|
||||
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
|
||||
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
|
||||
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
|
||||
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
@@ -116,10 +117,13 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<ReasoningConfig>(&cfg, &mut output);
|
||||
push_decl::<ContextTokenSource>(&cfg, &mut output);
|
||||
push_decl::<ContextUsage>(&cfg, &mut output);
|
||||
push_decl::<Greeting>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||
push_decl::<Greeting>(&cfg, &mut output);
|
||||
push_decl::<Alert>(&cfg, &mut output);
|
||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
|
||||
@@ -256,20 +260,71 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
));
|
||||
let pending =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id,
|
||||
worker_id: worker_id.clone(),
|
||||
event: Event::PendingSubmissionsChanged {
|
||||
pending: PendingSubmissionsSnapshot::default(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
for fixture in [&subscribed, &pending] {
|
||||
let greeting = Greeting {
|
||||
worker_name: "fixture-worker".into(),
|
||||
cwd: "/fixture".into(),
|
||||
provider: "openai_responses".into(),
|
||||
model: "gpt-6-astra".into(),
|
||||
reasoning: Some(ReasoningConfig::Effort {
|
||||
effort: "high".into(),
|
||||
}),
|
||||
scope_summary: "fixture scope".into(),
|
||||
tools: vec!["Read".into()],
|
||||
context_window: 272_000,
|
||||
context_tokens: 142_000,
|
||||
context_usage: Some(ContextUsage {
|
||||
tokens: 142_000,
|
||||
source: ContextTokenSource::Measured,
|
||||
}),
|
||||
};
|
||||
let snapshot =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id,
|
||||
event: Event::Snapshot {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: greeting.clone(),
|
||||
state: WorkerStateSnapshot::initial(),
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: vec![InternalWorkerSnapshot {
|
||||
worker: InternalWorkerRef {
|
||||
session_id: "fixture-child-session".into(),
|
||||
name: "fixture-child".into(),
|
||||
parent_session_id: Some("fixture-parent-session".into()),
|
||||
kind: InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 1,
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Some(greeting),
|
||||
status: WorkerStatus::Idle,
|
||||
error: None,
|
||||
in_flight: InFlightSnapshot::default(),
|
||||
internal_workers: Vec::new(),
|
||||
}],
|
||||
},
|
||||
},
|
||||
}));
|
||||
for fixture in [&subscribed, &pending, &snapshot] {
|
||||
fixture
|
||||
.validate()
|
||||
.expect("Rust-serialized Browser compatibility fixture must validate");
|
||||
}
|
||||
vec![subscribed, pending]
|
||||
vec![subscribed, pending, snapshot]
|
||||
}
|
||||
|
||||
fn close_object_shapes(schema: &mut Value) {
|
||||
|
||||
+79
-10
@@ -1403,7 +1403,9 @@ impl App {
|
||||
output_tokens,
|
||||
cache_read_input_tokens,
|
||||
} => {
|
||||
self.session_context_tokens = input_tokens.unwrap_or(0);
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
self.session_context_tokens = input_tokens;
|
||||
}
|
||||
// Subtract the cache-hit portion so a tool loop that
|
||||
// re-sends the same prefix on every request doesn't
|
||||
// re-count it. cache_creation stays in (it is full
|
||||
@@ -1475,7 +1477,6 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
self.active_compaction = None;
|
||||
self.session_context_tokens = 0;
|
||||
let new_segment_id = lifecycle
|
||||
.new_segment_id
|
||||
.as_deref()
|
||||
@@ -2337,8 +2338,12 @@ impl App {
|
||||
) -> InternalWorkerView {
|
||||
let mut app = App::new(snapshot.worker.name.clone());
|
||||
app.mode = mode;
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
if let Some(greeting) = snapshot.greeting {
|
||||
app.restore_snapshot(&snapshot.session, greeting, snapshot.in_flight);
|
||||
} else {
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
}
|
||||
app.set_worker_status(snapshot.status);
|
||||
if let Some(error) = snapshot.error {
|
||||
let _ = app.handle_worker_event(Event::Error {
|
||||
@@ -2424,7 +2429,10 @@ impl App {
|
||||
) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.session_context_tokens = greeting
|
||||
.context_usage
|
||||
.map(|usage| usage.tokens)
|
||||
.unwrap_or(greeting.context_tokens);
|
||||
self.restore_session(session, Some(greeting));
|
||||
self.apply_in_flight_snapshot(in_flight);
|
||||
}
|
||||
@@ -2441,10 +2449,15 @@ impl App {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let greeting = greeting.map(|mut greeting| {
|
||||
greeting.context_tokens = 0;
|
||||
greeting.context_usage = None;
|
||||
greeting
|
||||
});
|
||||
if let Some(greeting) = greeting.clone() {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.session_context_tokens = 0;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_session(session, greeting);
|
||||
@@ -2836,6 +2849,8 @@ mod rewind_refresh_tests {
|
||||
assert!(!blocks_contain(&app, "old post-target output"));
|
||||
assert_eq!(composer_text(&app), "selected rewind input");
|
||||
assert!(blocks_contain(&app, "Rewound session"));
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
assert_eq!(app.greeting.as_ref().and_then(|g| g.context_usage), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2946,6 +2961,8 @@ mod rewind_refresh_tests {
|
||||
tools: vec![],
|
||||
context_window: 100,
|
||||
context_tokens: 10,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4021,6 +4038,7 @@ mod completion_flow_tests {
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -4031,6 +4049,28 @@ mod completion_flow_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_worker_snapshot_uses_child_greeting_metadata() {
|
||||
let mut app = App::new("parent".into());
|
||||
let mut child = test_internal_worker_snapshot("child", "research", 1);
|
||||
let mut greeting = test_greeting();
|
||||
greeting.worker_name = "research".into();
|
||||
greeting.model = "child-model".into();
|
||||
greeting.context_window = 64_000;
|
||||
greeting.context_usage = Some(protocol::ContextUsage {
|
||||
tokens: 12_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
child.greeting = Some(greeting);
|
||||
|
||||
app.replace_internal_worker_snapshots(vec![child]);
|
||||
|
||||
let child_app = &app.internal_workers[0].app;
|
||||
assert_eq!(child_app.greeting.as_ref().unwrap().model, "child-model");
|
||||
assert_eq!(child_app.context_window, 64_000);
|
||||
assert_eq!(child_app.session_context_tokens, 12_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() {
|
||||
let mut app = App::new("parent".into());
|
||||
@@ -4309,6 +4349,7 @@ mod completion_flow_tests {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: None,
|
||||
status: WorkerStatus::Running,
|
||||
error: None,
|
||||
in_flight: Default::default(),
|
||||
@@ -4505,15 +4546,21 @@ mod completion_flow_tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_initializes_context_usage() {
|
||||
fn snapshot_prefers_typed_context_usage_over_legacy_mirror() {
|
||||
let mut app = App::new("test".into());
|
||||
let mut greeting = test_greeting();
|
||||
greeting.context_window = 123_000;
|
||||
greeting.context_tokens = 45_000;
|
||||
greeting.context_tokens = 1;
|
||||
greeting.context_usage = Some(protocol::ContextUsage {
|
||||
tokens: 45_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
@@ -4530,6 +4577,28 @@ mod completion_flow_tests {
|
||||
assert_eq!(app.session_context_tokens, 45_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_falls_back_to_legacy_context_tokens() {
|
||||
let mut app = App::new("test".into());
|
||||
let mut greeting = test_greeting();
|
||||
greeting.context_window = 100_000;
|
||||
greeting.context_tokens = 25_000;
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
state: test_worker_state(WorkerStatus::Idle),
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(app.context_window, 100_000);
|
||||
assert_eq!(app.session_context_tokens, 25_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_updates_session_context_tokens_without_cache_discount() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -4566,7 +4635,7 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_done_resets_session_context_tokens() {
|
||||
fn legacy_compact_done_does_not_fabricate_zero_context_usage() {
|
||||
let mut app = App::new("test".into());
|
||||
app.session_context_tokens = 42_000;
|
||||
|
||||
@@ -4574,7 +4643,7 @@ mod completion_flow_tests {
|
||||
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
|
||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||
|
||||
assert_eq!(app.session_context_tokens, 0);
|
||||
assert_eq!(app.session_context_tokens, 42_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -499,10 +499,12 @@ impl RetainedSnapshotSocket {
|
||||
cwd: String::new(),
|
||||
provider: "retained session".to_string(),
|
||||
model: String::new(),
|
||||
reasoning: None,
|
||||
scope_summary: "read-only retained session".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStateSnapshot {
|
||||
last_command_id: 0,
|
||||
@@ -654,10 +656,12 @@ async fn run_e2e_rewind_fixture(
|
||||
cwd: workspace_root.display().to_string(),
|
||||
provider: "e2e-fixture".to_string(),
|
||||
model: "canned".to_string(),
|
||||
reasoning: None,
|
||||
scope_summary: "isolated e2e rewind fixture".to_string(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2784,10 +2788,12 @@ mod tests {
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
reasoning: None,
|
||||
scope_summary: "".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3992,6 +3992,8 @@ mod ws_tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStateSnapshot::initial(),
|
||||
in_flight: protocol::InFlightSnapshot {
|
||||
|
||||
@@ -5474,6 +5474,7 @@ mod tests {
|
||||
worker,
|
||||
revision,
|
||||
status,
|
||||
greeting: None,
|
||||
session: protocol::SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries: Vec::new(),
|
||||
@@ -5499,6 +5500,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStatus::Idle.into(),
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
@@ -8111,6 +8114,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 128,
|
||||
context_tokens: 64,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: protocol::WorkerStatus::Running.into(),
|
||||
in_flight: protocol::InFlightSnapshot {
|
||||
|
||||
@@ -4520,6 +4520,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 1_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
hub.workers.lock().unwrap().insert(
|
||||
|
||||
@@ -151,7 +151,7 @@ impl WorkerHandle {
|
||||
.snapshot();
|
||||
let event = Event::Snapshot {
|
||||
session,
|
||||
greeting: self.shared_state.greeting.clone(),
|
||||
greeting: self.shared_state.greeting(),
|
||||
state: self.shared_state.snapshot(),
|
||||
in_flight,
|
||||
internal_workers: self.spawned_registry.internal_worker_snapshots(),
|
||||
@@ -303,6 +303,10 @@ async fn finish_controller_run<C, St>(
|
||||
// the terminal run boundary so reconnect snapshots cannot append stale
|
||||
// partial text/tool arguments after newer entries.
|
||||
worker.clear_in_flight_events();
|
||||
shared_state.update_context_usage(
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
||||
}
|
||||
|
||||
@@ -792,6 +796,11 @@ impl WorkerController {
|
||||
manifest_toml.clone(),
|
||||
greeting,
|
||||
));
|
||||
let usage_state = shared_state.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
usage_state
|
||||
.update_context_usage(event.input_tokens, protocol::ContextTokenSource::Measured);
|
||||
});
|
||||
if let Some(fs_for_view) = fs_for_view {
|
||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||
}
|
||||
@@ -2509,6 +2518,10 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
};
|
||||
shared_state.update_context_usage(
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
if !matches!(
|
||||
result,
|
||||
Err(WorkerError::Store(_))
|
||||
@@ -2576,6 +2589,10 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
{
|
||||
worker.clear_in_flight_events();
|
||||
shared_state.update_context_usage(
|
||||
Some(worker.total_tokens().tokens),
|
||||
protocol::ContextTokenSource::Estimated,
|
||||
);
|
||||
let snapshot = shared_state.transition(WorkerState::Idle);
|
||||
let _ = working_event_tx.send(Event::WorkerState { snapshot });
|
||||
}
|
||||
@@ -3383,7 +3400,24 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||
fn public_reasoning_config(
|
||||
reasoning: &agen::llm_client::capability::ReasoningControl,
|
||||
) -> protocol::ReasoningConfig {
|
||||
match reasoning {
|
||||
agen::llm_client::capability::ReasoningControl::Effort(effort) => {
|
||||
protocol::ReasoningConfig::Effort {
|
||||
effort: effort.as_str().to_owned(),
|
||||
}
|
||||
}
|
||||
agen::llm_client::capability::ReasoningControl::BudgetTokens(budget_tokens) => {
|
||||
protocol::ReasoningConfig::BudgetTokens {
|
||||
budget_tokens: *budget_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
St: Store,
|
||||
@@ -3426,6 +3460,7 @@ where
|
||||
.into_iter()
|
||||
.map(|def| def.name)
|
||||
.collect();
|
||||
let context_tokens = worker.total_tokens().tokens;
|
||||
protocol::Greeting {
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
cwd: worker
|
||||
@@ -3434,10 +3469,19 @@ where
|
||||
.unwrap_or_default(),
|
||||
provider: provider_name,
|
||||
model: model_id,
|
||||
reasoning: manifest
|
||||
.engine
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.map(public_reasoning_config),
|
||||
scope_summary: worker.scope_snapshot().summary(),
|
||||
tools: tool_names,
|
||||
context_window,
|
||||
context_tokens: worker.total_tokens().tokens,
|
||||
context_tokens,
|
||||
context_usage: Some(protocol::ContextUsage {
|
||||
tokens: context_tokens,
|
||||
source: protocol::ContextTokenSource::Estimated,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3500,6 +3544,28 @@ mod tests {
|
||||
assert!(materialize < exposure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_reasoning_config_preserves_effort_and_signed_budget() {
|
||||
let effort =
|
||||
public_reasoning_config(&agen::llm_client::capability::ReasoningControl::Effort(
|
||||
agen::llm_client::capability::ReasoningEffort::Other("provider-native".into()),
|
||||
));
|
||||
assert_eq!(
|
||||
effort,
|
||||
protocol::ReasoningConfig::Effort {
|
||||
effort: "provider-native".into(),
|
||||
}
|
||||
);
|
||||
|
||||
let budget = public_reasoning_config(
|
||||
&agen::llm_client::capability::ReasoningControl::BudgetTokens(-1),
|
||||
);
|
||||
assert_eq!(
|
||||
budget,
|
||||
protocol::ReasoningConfig::BudgetTokens { budget_tokens: -1 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_memory_extraction_destinations_are_mutually_exclusive() {
|
||||
let mut memory = manifest::MemoryFeatureProfileConfig::default();
|
||||
@@ -3658,6 +3724,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
));
|
||||
let notify_buffer = NotifyBuffer::new();
|
||||
@@ -3707,6 +3775,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -4415,6 +4485,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 1,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -1507,6 +1507,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1543,6 +1545,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1634,6 +1638,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1661,6 +1667,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1765,6 +1773,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Paused.into(),
|
||||
in_flight: Default::default(),
|
||||
@@ -1820,6 +1830,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! need durable domain audit must keep using their domain authority (for example Memory audit).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use agen::timeline::event::UsageEvent;
|
||||
use agen::{Engine, EngineError, llm_client::LlmClient};
|
||||
@@ -17,7 +17,9 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events};
|
||||
use crate::controller::{
|
||||
build_greeting, wire_event_bridges_on_engine, wire_workdir_command_events,
|
||||
};
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::in_flight::{InFlightEvents, snapshot_from_guard};
|
||||
use crate::ipc::alerter::Alerter;
|
||||
@@ -409,6 +411,7 @@ enum InternalWorkerSessionCommand {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalWorkerSessionSnapshot {
|
||||
pub session: protocol::SessionSnapshot,
|
||||
pub greeting: Option<protocol::Greeting>,
|
||||
pub status: WorkerStatus,
|
||||
pub error: Option<String>,
|
||||
pub in_flight: InFlightSnapshot,
|
||||
@@ -428,6 +431,7 @@ pub(crate) struct InternalWorkerSessionHandle {
|
||||
visibility: InternalWorkerVisibility,
|
||||
last_error: Arc<Mutex<Option<String>>>,
|
||||
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
|
||||
greeting: Option<Arc<RwLock<protocol::Greeting>>>,
|
||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||
sink: SegmentLogSink,
|
||||
#[cfg(test)]
|
||||
@@ -481,6 +485,12 @@ impl InternalWorkerSessionHandle {
|
||||
};
|
||||
InternalWorkerSessionSnapshot {
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
greeting: self.greeting.as_ref().map(|greeting| {
|
||||
greeting
|
||||
.read()
|
||||
.expect("internal Worker greeting lock poisoned")
|
||||
.clone()
|
||||
}),
|
||||
status: match self.status() {
|
||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
@@ -851,7 +861,19 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
worker.attach_alerter(alerter.clone());
|
||||
worker.attach_working_event_tx(event_tx.clone());
|
||||
worker.attach_in_flight_events(in_flight.clone());
|
||||
let greeting = Arc::new(RwLock::new(build_greeting(&worker)));
|
||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||
let usage_greeting = greeting.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
let mut greeting = usage_greeting
|
||||
.write()
|
||||
.expect("internal Worker greeting lock poisoned");
|
||||
greeting.context_tokens = event.input_tokens.unwrap_or_default();
|
||||
greeting.context_usage = event.input_tokens.map(|tokens| protocol::ContextUsage {
|
||||
tokens,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
});
|
||||
});
|
||||
|
||||
let session_id = worker.session_id();
|
||||
let segment_id = worker.segment_id();
|
||||
@@ -874,6 +896,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
||||
visibility,
|
||||
last_error: last_error.clone(),
|
||||
last_outcome: last_outcome.clone(),
|
||||
greeting: Some(greeting),
|
||||
child_registry,
|
||||
sink,
|
||||
#[cfg(test)]
|
||||
@@ -1206,6 +1229,7 @@ pub(crate) fn test_internal_worker_session(
|
||||
visibility,
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
last_outcome: Arc::new(Mutex::new(None)),
|
||||
greeting: None,
|
||||
child_registry: None,
|
||||
sink,
|
||||
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
|
||||
@@ -212,6 +212,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct WorkerSharedState {
|
||||
pub worker_name: String,
|
||||
pub segment_id: SegmentId,
|
||||
pub manifest_toml: String,
|
||||
pub greeting: protocol::Greeting,
|
||||
greeting: RwLock<protocol::Greeting>,
|
||||
state: RwLock<WorkerStateSnapshot>,
|
||||
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
|
||||
/// Worker-from-the-inside view of the filesystem. Set once in
|
||||
@@ -62,7 +62,7 @@ impl WorkerSharedState {
|
||||
worker_name,
|
||||
segment_id,
|
||||
manifest_toml,
|
||||
greeting,
|
||||
greeting: RwLock::new(greeting),
|
||||
state: RwLock::new(WorkerStateSnapshot::initial()),
|
||||
accepted_commands: RwLock::new(VecDeque::new()),
|
||||
fs_view: OnceLock::new(),
|
||||
@@ -90,6 +90,22 @@ impl WorkerSharedState {
|
||||
self.flow_transition_enabled.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn greeting(&self) -> protocol::Greeting {
|
||||
self.greeting
|
||||
.read()
|
||||
.expect("worker greeting lock poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn update_context_usage(&self, tokens: Option<u64>, source: protocol::ContextTokenSource) {
|
||||
let mut greeting = self
|
||||
.greeting
|
||||
.write()
|
||||
.expect("worker greeting lock poisoned");
|
||||
greeting.context_tokens = tokens.unwrap_or_default();
|
||||
greeting.context_usage = tokens.map(|tokens| protocol::ContextUsage { tokens, source });
|
||||
}
|
||||
|
||||
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
|
||||
let mut snapshot = self
|
||||
.state
|
||||
@@ -245,9 +261,32 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_context_usage_is_reflected_in_reconnect_greeting() {
|
||||
let state = test_state();
|
||||
state.update_context_usage(Some(42_000), protocol::ContextTokenSource::Measured);
|
||||
|
||||
let greeting = state.greeting();
|
||||
assert_eq!(greeting.context_tokens, 42_000);
|
||||
assert_eq!(
|
||||
greeting.context_usage,
|
||||
Some(protocol::ContextUsage {
|
||||
tokens: 42_000,
|
||||
source: protocol::ContextTokenSource::Measured,
|
||||
})
|
||||
);
|
||||
|
||||
state.update_context_usage(None, protocol::ContextTokenSource::Measured);
|
||||
let greeting = state.greeting();
|
||||
assert_eq!(greeting.context_tokens, 0);
|
||||
assert_eq!(greeting.context_usage, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_snapshot_is_idle() {
|
||||
let state = test_state();
|
||||
|
||||
@@ -97,6 +97,8 @@ mod tests {
|
||||
tools: Vec::new(),
|
||||
context_window: 200_000,
|
||||
context_tokens: 0,
|
||||
reasoning: None,
|
||||
context_usage: None,
|
||||
},
|
||||
state: WorkerStatus::Idle.into(),
|
||||
in_flight: Default::default(),
|
||||
|
||||
@@ -995,6 +995,7 @@ fn internal_worker_snapshot(
|
||||
worker,
|
||||
revision,
|
||||
session: snapshot.session,
|
||||
greeting: snapshot.greeting,
|
||||
status: snapshot.status,
|
||||
error: snapshot.error,
|
||||
in_flight: snapshot.in_flight,
|
||||
|
||||
Reference in New Issue
Block a user