blob: 5a722745e49fd6689e0e96f004f09037fad91521 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{
Ownership, RESOURCE_REF_PREFIX, ResourceUpdate, SNAPSHOT_MAX_BYTES, ShellMode, ShellOutput,
ShellSnapshot, ShellStatus,
};
use crate::ProjectionError;
use maka_agent_facts::shell_run::ShellOutcome;
use maka_agent_facts::shell_run::ShellOutput as StoredOutput;
use maka_agent_facts::shell_run::ShellState;
use maka_agent_facts::shell_run::{ShellObservation, ShellRun};
use maka_agent_facts::terminal::TerminalScreen;
use std::sync::Arc;
const MODEL_BYTES: usize = maka_agent_facts::terminal::PREVIEW_BYTES;
const TERMINAL_MARKER: &str = "[terminal snapshot truncated to fit the output limit]";
const FIELD_MARKER: &str = "[runtime resource field truncated]";
const RECOVERY: &str = "If the command is safe to re-run, redirect its output to a file (e.g. `cmd > out.txt 2>&1`) then Read or Grep that file for the omitted portion. If re-running could repeat side effects, do not. Otherwise work from the kept output above.";
pub fn local_update(record: ShellRun) -> Result<ResourceUpdate, ProjectionError> {
project(record, true, None)
}
pub fn local_observation(observation: ShellObservation) -> Result<ResourceUpdate, ProjectionError> {
project((*observation.record).clone(), true, observation.preview)
}
pub fn local_state(record: ShellRun) -> Result<ShellSnapshot, ProjectionError> {
local_metadata(record).map(|update| update.result)
}
/// The same canonical ownership/source envelope without terminal output.
pub fn local_metadata(record: ShellRun) -> Result<ResourceUpdate, ProjectionError> {
project(record, false, None)
}
fn project(
record: ShellRun,
include_output: bool,
preview: Option<Arc<TerminalScreen>>,
) -> Result<ResourceUpdate, ProjectionError> {
record
.validate_metadata()
.map_err(ProjectionError::Invalid)?;
let (status, completed_at, exit_code, failure_message) = match record.state {
ShellState::Starting => (ShellStatus::Starting, None, None, None),
ShellState::Running => (ShellStatus::Running, None, None, None),
ShellState::Terminal {
completed_at,
outcome,
..
} => {
let code = outcome.exit_code();
let (status, message) = match outcome {
ShellOutcome::Completed => (ShellStatus::Completed, None),
ShellOutcome::Exited { message, .. } => (ShellStatus::Failed, message),
ShellOutcome::Failed { message } => (ShellStatus::Failed, Some(message)),
ShellOutcome::TimedOut { message } => (ShellStatus::TimedOut, message),
ShellOutcome::Cancelled { message } => (ShellStatus::Cancelled, message),
ShellOutcome::Orphaned { message } => (ShellStatus::Orphaned, Some(message)),
};
(status, Some(completed_at), code, message)
}
};
let mode = if record.output.is_pty() {
ShellMode::Pty
} else {
ShellMode::Pipes
};
let mut result = ShellSnapshot {
kind: super::SnapshotKind::ShellRun,
resource_ref: format!("{RESOURCE_REF_PREFIX}{}", record.id),
mode,
status,
cwd: record.cwd,
cmd: record.command,
started_at: record.started_at,
updated_at: record.updated_at,
completed_at,
timeout_ms: record.timeout_ms,
exit_code,
failure_message,
revision: record.revision,
output: include_output.then(|| model_output(record.output, preview)),
};
bound(&mut result)?;
Ok(ResourceUpdate {
session_id: record.session_id,
ownership: Ownership::Local,
source_turn_id: record.source_turn_id,
source_tool_call_id: record.source_tool_call_id,
result,
})
}
fn model_output(output: StoredOutput, preview: Option<Arc<TerminalScreen>>) -> ShellOutput {
match output {
StoredOutput::Pipes {
stdout,
stderr,
latest_stream,
stdout_truncated,
stderr_truncated,
} => {
let (stdout, out_cut) = pipe_tail(stdout);
let (stderr, err_cut) = pipe_tail(stderr);
ShellOutput::Pipes {
stdout,
stderr,
latest_stream,
stdout_truncated: stdout_truncated || out_cut,
stderr_truncated: stderr_truncated || err_cut,
redacted: false,
}
}
StoredOutput::Pty { screen: display } => {
let view = preview
.as_deref()
.cloned()
.unwrap_or_else(|| display.text(MODEL_BYTES));
let mut remaining = MODEL_BYTES;
let (screen, screen_cut) = prioritized(view.screen, &mut remaining);
let alternate = view
.last_alternate_screen
.map(|s| prioritized(s, &mut remaining));
let (scrollback, scroll_cut) = tail(view.scrollback, remaining, TERMINAL_MARKER);
let alternate_cut = alternate.as_ref().is_some_and(|(_, cut)| *cut);
ShellOutput::Pty {
screen,
scrollback,
last_alternate_screen: alternate.map(|(s, _)| s).filter(|s| !s.is_empty()),
cols: view.size.cols(),
rows: view.size.rows(),
cursor: view.cursor,
alternate_screen: view.alternate_screen,
truncated: view.truncated || screen_cut || scroll_cut || alternate_cut,
redacted: false,
}
}
}
}
fn prioritized(text: String, remaining: &mut usize) -> (String, bool) {
let (text, cut) = tail(text, *remaining, TERMINAL_MARKER);
*remaining = if cut {
0
} else {
remaining.saturating_sub(text.len())
};
(text, cut)
}
fn tail(text: String, budget: usize, marker: &str) -> (String, bool) {
if text.len() <= budget {
return (text, false);
}
let Some(bytes) = budget.checked_sub(marker.len() + 1) else {
return (String::new(), true);
};
let start = text.ceil_char_boundary(text.len().saturating_sub(bytes));
(format!("{marker}\n{}", &text[start..]), true)
}
// Same model-facing line/byte policy as runtime/tool-output.ts; no spill file,
// and the recovery hint explicitly forbids repeating unsafe side effects.
fn pipe_tail(text: String) -> (String, bool) {
let body = text.strip_suffix('\n').unwrap_or(&text);
let lines = body.split('\n').count();
if lines <= 2000 && text.len() <= MODEL_BYTES {
return (text, false);
}
let mut start = body.len();
let mut retained = 0;
let mut hit_bytes = false;
for line in body.rsplit('\n').take(2000) {
let candidate = start.saturating_sub(line.len() + usize::from(retained > 0));
if body.len() - candidate > MODEL_BYTES {
hit_bytes = true;
break;
}
start = candidate;
retained += 1;
}
if retained == 0 {
start = body.ceil_char_boundary(body.len().saturating_sub(MODEL_BYTES));
hit_bytes = true;
}
let removed = if hit_bytes {
text.len() - (body.len() - start)
} else {
lines - retained
};
if removed == 0 {
return (text, false);
}
let unit = if hit_bytes { "bytes" } else { "lines" };
(
format!(
"...{removed} {unit} truncated. {RECOVERY}\n\n{}",
&body[start..]
),
true,
)
}
#[derive(Clone, Copy)]
enum Field {
Command,
Cwd,
Failure,
Stdout,
Stderr,
Screen,
Scrollback,
Alternate,
}
fn bound(snapshot: &mut ShellSnapshot) -> Result<(), ProjectionError> {
while serde_json::to_vec(snapshot)
.map_err(|_| ProjectionError::Invalid("shell serialization failed"))?
.len()
> SNAPSHOT_MAX_BYTES
{
let mut fields = vec![
(Field::Command, snapshot.cmd.len()),
(Field::Cwd, snapshot.cwd.len()),
];
if let Some(message) = &snapshot.failure_message {
fields.push((Field::Failure, message.len()));
}
match &snapshot.output {
Some(ShellOutput::Pipes { stdout, stderr, .. }) => {
fields.extend([(Field::Stdout, stdout.len()), (Field::Stderr, stderr.len())])
}
Some(ShellOutput::Pty {
screen,
scrollback,
last_alternate_screen,
..
}) => {
fields.extend([
(Field::Screen, screen.len()),
(Field::Scrollback, scrollback.len()),
]);
if let Some(alternate) = last_alternate_screen {
fields.push((Field::Alternate, alternate.len()));
}
}
None => {}
}
// Preserve source field order for equal byte lengths.
let (field, length) = fields
.into_iter()
.reduce(|left, right| if right.1 > left.1 { right } else { left })
.unwrap();
if length == 0 {
return Err(ProjectionError::TooLarge);
}
let keep_head = matches!(field, Field::Command | Field::Cwd | Field::Failure);
let value = field_mut(snapshot, field);
let budget = value.len() / 2;
*value = if budget <= FIELD_MARKER.len() + 1 {
String::new()
} else if keep_head {
let end = value.floor_char_boundary(budget - FIELD_MARKER.len() - 1);
format!("{}\n{FIELD_MARKER}", &value[..end])
} else {
tail(std::mem::take(value), budget, FIELD_MARKER).0
};
}
snapshot.validate().map_err(ProjectionError::Invalid)
}
fn field_mut(snapshot: &mut ShellSnapshot, field: Field) -> &mut String {
match field {
Field::Command => &mut snapshot.cmd,
Field::Cwd => &mut snapshot.cwd,
Field::Failure => snapshot.failure_message.as_mut().unwrap(),
_ => match snapshot.output.as_mut().unwrap() {
ShellOutput::Pipes {
stdout,
stderr,
stdout_truncated,
stderr_truncated,
..
} => match field {
Field::Stdout => {
*stdout_truncated = true;
stdout
}
Field::Stderr => {
*stderr_truncated = true;
stderr
}
_ => unreachable!(),
},
ShellOutput::Pty {
screen,
scrollback,
last_alternate_screen,
truncated,
..
} => {
*truncated = true;
match field {
Field::Screen => screen,
Field::Scrollback => scrollback,
Field::Alternate => last_alternate_screen.as_mut().unwrap(),
_ => unreachable!(),
}
}
},
}
}