cargo fmt
This commit is contained in:
parent
bcaa4645f7
commit
7a0ed7d744
|
|
@ -25,11 +25,7 @@ use llm_worker::llm_client::scheme::{
|
||||||
};
|
};
|
||||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
|
|
||||||
fn make_transport<S: Scheme>(
|
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||||
scheme: S,
|
|
||||||
model: &str,
|
|
||||||
auth: ResolvedAuth,
|
|
||||||
) -> HttpTransport<S> {
|
|
||||||
let cap = scheme.default_capability();
|
let cap = scheme.default_capability();
|
||||||
let base_url = scheme.default_base_url().to_string();
|
let base_url = scheme.default_base_url().to_string();
|
||||||
HttpTransport::new(scheme, model.to_string(), base_url, auth, cap)
|
HttpTransport::new(scheme, model.to_string(), base_url, auth, cap)
|
||||||
|
|
@ -71,11 +67,7 @@ async fn run_scenario_with_anthropic(
|
||||||
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
||||||
.expect("ANTHROPIC_API_KEY environment variable must be set");
|
.expect("ANTHROPIC_API_KEY environment variable must be set");
|
||||||
let model = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
|
let model = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
|
||||||
let client = make_transport(
|
let client = make_transport(AnthropicScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||||
AnthropicScheme::new(),
|
|
||||||
model,
|
|
||||||
ResolvedAuth::ApiKey(api_key),
|
|
||||||
);
|
|
||||||
|
|
||||||
recorder::record_request(
|
recorder::record_request(
|
||||||
&client,
|
&client,
|
||||||
|
|
|
||||||
|
|
@ -338,11 +338,7 @@ fn default_capability() -> ModelCapability {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_transport<S: Scheme>(
|
fn build_transport<S: Scheme>(scheme: S, model: String, auth: ResolvedAuth) -> Box<dyn LlmClient> {
|
||||||
scheme: S,
|
|
||||||
model: String,
|
|
||||||
auth: ResolvedAuth,
|
|
||||||
) -> Box<dyn LlmClient> {
|
|
||||||
let cap = scheme.default_capability();
|
let cap = scheme.default_capability();
|
||||||
let base_url = scheme.default_base_url().to_string();
|
let base_url = scheme.default_base_url().to_string();
|
||||||
Box::new(HttpTransport::new(scheme, model, base_url, auth, cap))
|
Box::new(HttpTransport::new(scheme, model, base_url, auth, cap))
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ use std::collections::BTreeSet;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
capability::{CacheStrategy, ModelCapability, ReasoningControl, ReasoningSupport},
|
|
||||||
types::{parse_tool_arguments, ContentPart, Item, Role, ToolDefinition},
|
|
||||||
Request,
|
Request,
|
||||||
|
capability::{CacheStrategy, ModelCapability, ReasoningControl, ReasoningSupport},
|
||||||
|
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::AnthropicScheme;
|
use super::AnthropicScheme;
|
||||||
|
|
@ -600,7 +600,7 @@ mod tests {
|
||||||
let scheme = AnthropicScheme::new();
|
let scheme = AnthropicScheme::new();
|
||||||
let mut items = completed_turn();
|
let mut items = completed_turn();
|
||||||
items.push(Item::user_message("next turn")); // index 5 = latest user
|
items.push(Item::user_message("next turn")); // index 5 = latest user
|
||||||
// cache_anchor=None, turn_end=4, head=5.
|
// cache_anchor=None, turn_end=4, head=5.
|
||||||
let request = Request::new().items(items);
|
let request = Request::new().items(items);
|
||||||
|
|
||||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
ClientError,
|
ClientError,
|
||||||
|
auth::AuthRequirement,
|
||||||
capability::ModelCapability,
|
capability::ModelCapability,
|
||||||
event::{BlockStop, BlockType, Event},
|
event::{BlockStop, BlockType, Event},
|
||||||
auth::AuthRequirement,
|
|
||||||
scheme::Scheme,
|
scheme::Scheme,
|
||||||
types::Request,
|
types::Request,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
|
||||||
types::{parse_tool_arguments, Item, Role, ToolDefinition},
|
|
||||||
Request,
|
Request,
|
||||||
|
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||||
|
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::GeminiScheme;
|
use super::GeminiScheme;
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
ClientError,
|
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||||
capability::ModelCapability,
|
|
||||||
event::Event,
|
|
||||||
auth::AuthRequirement,
|
|
||||||
scheme::Scheme,
|
|
||||||
types::Request,
|
types::Request,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,4 +90,3 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
|
||||||
types::{parse_tool_arguments, Item, Role, ToolDefinition},
|
|
||||||
Request,
|
Request,
|
||||||
|
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||||
|
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::OpenAIScheme;
|
use super::OpenAIScheme;
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,7 @@ impl OpenAIResponsesState {
|
||||||
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
|
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
|
||||||
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
|
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
|
||||||
/// 抜けたときの防御。
|
/// 抜けたときの防御。
|
||||||
fn get_or_allocate(
|
fn get_or_allocate(&mut self, key: SlotKey, block_type: BlockType) -> (SlotInfo, bool) {
|
||||||
&mut self,
|
|
||||||
key: SlotKey,
|
|
||||||
block_type: BlockType,
|
|
||||||
) -> (SlotInfo, bool) {
|
|
||||||
if let Some(info) = self.slots.get(&key).copied() {
|
if let Some(info) = self.slots.get(&key).copied() {
|
||||||
(info, false)
|
(info, false)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -303,15 +299,12 @@ pub(crate) fn parse_sse(
|
||||||
match ev.item {
|
match ev.item {
|
||||||
OutputItem::FunctionCall { call_id, name, .. }
|
OutputItem::FunctionCall { call_id, name, .. }
|
||||||
| OutputItem::CustomToolCall { call_id, name, .. } => {
|
| OutputItem::CustomToolCall { call_id, name, .. } => {
|
||||||
let info = state
|
let info =
|
||||||
.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
|
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
|
||||||
Ok(vec![Event::BlockStart(BlockStart {
|
Ok(vec![Event::BlockStart(BlockStart {
|
||||||
index: info.flat_index,
|
index: info.flat_index,
|
||||||
block_type: BlockType::ToolUse,
|
block_type: BlockType::ToolUse,
|
||||||
metadata: BlockMetadata::ToolUse {
|
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||||||
id: call_id,
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
})])
|
})])
|
||||||
}
|
}
|
||||||
_ => Ok(Vec::new()),
|
_ => Ok(Vec::new()),
|
||||||
|
|
@ -530,11 +523,7 @@ mod tests {
|
||||||
(events, state)
|
(events, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with(
|
fn with(state: &mut OpenAIResponsesState, event_type: &str, data: &str) -> Vec<Event> {
|
||||||
state: &mut OpenAIResponsesState,
|
|
||||||
event_type: &str,
|
|
||||||
data: &str,
|
|
||||||
) -> Vec<Event> {
|
|
||||||
parse_sse(event_type, data, state).unwrap()
|
parse_sse(event_type, data, state).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,7 +540,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completed_emits_usage_and_status() {
|
fn completed_emits_usage_and_status() {
|
||||||
let data = r#"{"response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
|
let data =
|
||||||
|
r#"{"response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
|
||||||
let (events, _) = run("response.completed", data);
|
let (events, _) = run("response.completed", data);
|
||||||
assert!(matches!(events[0], Event::Usage(_)));
|
assert!(matches!(events[0], Event::Usage(_)));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
@ -761,8 +751,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_response_emits_error_and_status() {
|
fn failed_response_emits_error_and_status() {
|
||||||
let data =
|
let data = r#"{"response":{"error":{"type":"invalid_request_error","message":"bad"}}}"#;
|
||||||
r#"{"response":{"error":{"type":"invalid_request_error","message":"bad"}}}"#;
|
|
||||||
let (events, _) = run("response.failed", data);
|
let (events, _) = run("response.failed", data);
|
||||||
assert_eq!(events.len(), 2);
|
assert_eq!(events.len(), 2);
|
||||||
assert!(matches!(events[0], Event::Error(_)));
|
assert!(matches!(events[0], Event::Error(_)));
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,7 @@
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::llm_client::{
|
||||||
ClientError,
|
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||||
auth::AuthRequirement,
|
|
||||||
capability::ModelCapability,
|
|
||||||
event::Event,
|
|
||||||
scheme::Scheme,
|
|
||||||
types::Request,
|
types::Request,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@ impl ResolvedAuth {
|
||||||
(Self::Custom(_), _) => true,
|
(Self::Custom(_), _) => true,
|
||||||
(
|
(
|
||||||
Self::ApiKey(_),
|
Self::ApiKey(_),
|
||||||
AuthRequirement::Bearer | AuthRequirement::XApiKey | AuthRequirement::QueryParam { .. },
|
AuthRequirement::Bearer
|
||||||
|
| AuthRequirement::XApiKey
|
||||||
|
| AuthRequirement::QueryParam { .. },
|
||||||
) => true,
|
) => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -568,9 +568,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||||
// Attach the cache prefix anchor (may be narrower than `context`
|
// Attach the cache prefix anchor (may be narrower than `context`
|
||||||
// if the prune projection trimmed items from the head — keep it
|
// if the prune projection trimmed items from the head — keep it
|
||||||
// in range).
|
// in range).
|
||||||
request.cache_anchor = self
|
request.cache_anchor = self.cache_anchor.filter(|&anchor| anchor < context.len());
|
||||||
.cache_anchor
|
|
||||||
.filter(|&anchor| anchor < context.len());
|
|
||||||
|
|
||||||
request
|
request
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,5 +121,4 @@ name = "from-disk"
|
||||||
_ => panic!("expected Io variant"),
|
_ => panic!("expected Io variant"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,10 +161,7 @@ mod tests {
|
||||||
"XDG_RUNTIME_DIR",
|
"XDG_RUNTIME_DIR",
|
||||||
"HOME",
|
"HOME",
|
||||||
];
|
];
|
||||||
let saved: Vec<_> = names
|
let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect();
|
||||||
.iter()
|
|
||||||
.map(|n| (*n, std::env::var(n).ok()))
|
|
||||||
.collect();
|
|
||||||
// SAFETY: env_lock() 取得済みなので env への並行アクセスは
|
// SAFETY: env_lock() 取得済みなので env への並行アクセスは
|
||||||
// この test バイナリ内では発生しない。
|
// この test バイナリ内では発生しない。
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
@ -206,10 +203,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_dir_uses_xdg_when_set() {
|
fn config_dir_uses_xdg_when_set() {
|
||||||
let _g = EnvGuard::new(&[
|
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("XDG_CONFIG_HOME", Some("/x"))]);
|
||||||
("HOME", Some("/h")),
|
|
||||||
("XDG_CONFIG_HOME", Some("/x")),
|
|
||||||
]);
|
|
||||||
assert_eq!(config_dir().unwrap(), PathBuf::from("/x/insomnia"));
|
assert_eq!(config_dir().unwrap(), PathBuf::from("/x/insomnia"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,10 +235,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_dir_insomnia_home_is_data_dir_itself() {
|
fn data_dir_insomnia_home_is_data_dir_itself() {
|
||||||
let _g = EnvGuard::new(&[
|
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("INSOMNIA_HOME", Some("/sand"))]);
|
||||||
("HOME", Some("/h")),
|
|
||||||
("INSOMNIA_HOME", Some("/sand")),
|
|
||||||
]);
|
|
||||||
assert_eq!(data_dir().unwrap(), PathBuf::from("/sand"));
|
assert_eq!(data_dir().unwrap(), PathBuf::from("/sand"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,10 +269,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_env_treated_as_unset() {
|
fn empty_env_treated_as_unset() {
|
||||||
let _g = EnvGuard::new(&[
|
let _g = EnvGuard::new(&[("HOME", Some("/h")), ("XDG_CONFIG_HOME", Some(""))]);
|
||||||
("HOME", Some("/h")),
|
|
||||||
("XDG_CONFIG_HOME", Some("")),
|
|
||||||
]);
|
|
||||||
assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia"));
|
assert_eq!(config_dir().unwrap(), PathBuf::from("/h/.config/insomnia"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,10 +300,7 @@ mod tests {
|
||||||
user_catalog_override("providers.toml").unwrap(),
|
user_catalog_override("providers.toml").unwrap(),
|
||||||
PathBuf::from("/sand/config/providers.toml")
|
PathBuf::from("/sand/config/providers.toml")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(sessions_dir().unwrap(), PathBuf::from("/sand/sessions"));
|
||||||
sessions_dir().unwrap(),
|
|
||||||
PathBuf::from("/sand/sessions")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scope_lock_path().unwrap(),
|
scope_lock_path().unwrap(),
|
||||||
PathBuf::from("/sand/run/scope.lock")
|
PathBuf::from("/sand/run/scope.lock")
|
||||||
|
|
|
||||||
|
|
@ -61,13 +61,17 @@ pub enum LintError {
|
||||||
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
||||||
InvalidStatus(String),
|
InvalidStatus(String),
|
||||||
|
|
||||||
#[error("Knowledge with model_invokation: true cannot have description longer than {limit} chars (got {actual})")]
|
#[error(
|
||||||
|
"Knowledge with model_invokation: true cannot have description longer than {limit} chars (got {actual})"
|
||||||
|
)]
|
||||||
DescriptionTooLong { actual: usize, limit: usize },
|
DescriptionTooLong { actual: usize, limit: usize },
|
||||||
|
|
||||||
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
|
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
|
||||||
BodyTooLong { actual: usize, limit: usize },
|
BodyTooLong { actual: usize, limit: usize },
|
||||||
|
|
||||||
#[error("write to `memory/workflow/` is forbidden via the memory tool — Workflows are human-edited")]
|
#[error(
|
||||||
|
"write to `memory/workflow/` is forbidden via the memory tool — Workflows are human-edited"
|
||||||
|
)]
|
||||||
WorkflowWriteForbidden,
|
WorkflowWriteForbidden,
|
||||||
|
|
||||||
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
|
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
|
||||||
|
|
|
||||||
|
|
@ -208,23 +208,13 @@ impl Linter {
|
||||||
report.push_error(LintError::ReplacedBySelf);
|
report.push_error(LintError::ReplacedBySelf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
references::check_replaced_by(
|
references::check_replaced_by(cp.slug.as_ref(), target, existing, report);
|
||||||
cp.slug.as_ref(),
|
|
||||||
target,
|
|
||||||
existing,
|
|
||||||
report,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_knowledge(
|
fn check_knowledge(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport) {
|
||||||
&self,
|
|
||||||
content: &str,
|
|
||||||
cp: &ClassifiedPath,
|
|
||||||
report: &mut LintReport,
|
|
||||||
) {
|
|
||||||
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
|
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -236,8 +226,7 @@ impl Linter {
|
||||||
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
|
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
|
||||||
|
|
||||||
if fm.model_invokation
|
if fm.model_invokation
|
||||||
&& fm.description.chars().count()
|
&& fm.description.chars().count() > crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
|
||||||
> crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
|
|
||||||
{
|
{
|
||||||
report.push_error(LintError::DescriptionTooLong {
|
report.push_error(LintError::DescriptionTooLong {
|
||||||
actual: fm.description.chars().count(),
|
actual: fm.description.chars().count(),
|
||||||
|
|
@ -339,7 +328,12 @@ mod tests {
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::WorkflowWriteForbidden)));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::WorkflowWriteForbidden))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -347,7 +341,12 @@ mod tests {
|
||||||
let (dir, linter) = workspace();
|
let (dir, linter) = workspace();
|
||||||
let path = dir.path().join("src/main.rs");
|
let path = dir.path().join("src/main.rs");
|
||||||
let report = linter.lint(&path, "ignored", WriteMode::Create);
|
let report = linter.lint(&path, "ignored", WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::InvalidPath(_))));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::InvalidPath(_)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -359,10 +358,12 @@ mod tests {
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(
|
assert!(
|
||||||
e,
|
report
|
||||||
LintError::UnknownReference { .. }
|
.errors
|
||||||
)));
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::UnknownReference { .. }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -374,7 +375,12 @@ mod tests {
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::ReplacedBySelf)));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::ReplacedBySelf))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -424,7 +430,12 @@ mod tests {
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::DescriptionTooLong { .. })));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::DescriptionTooLong { .. }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -468,7 +479,12 @@ mod tests {
|
||||||
now = iso_now()
|
now = iso_now()
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::SlugAlreadyExists(_))));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::SlugAlreadyExists(_)))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -549,7 +565,11 @@ mod tests {
|
||||||
.warnings
|
.warnings
|
||||||
.iter()
|
.iter()
|
||||||
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
|
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
|
||||||
assert!(warned, "expected SimilarSlugs warning, got {:?}", report.warnings);
|
assert!(
|
||||||
|
warned,
|
||||||
|
"expected SimilarSlugs warning, got {:?}",
|
||||||
|
report.warnings
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -591,7 +611,12 @@ mod tests {
|
||||||
body = big_body
|
body = big_body
|
||||||
);
|
);
|
||||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::BodyTooLong { .. })));
|
assert!(
|
||||||
|
report
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, LintError::BodyTooLong { .. }))
|
||||||
|
);
|
||||||
// Sanity: ensure path was treated as PathBuf consistently.
|
// Sanity: ensure path was treated as PathBuf consistently.
|
||||||
let _ = PathBuf::from(path);
|
let _ = PathBuf::from(path);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,7 @@ pub fn check_replaced_by(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chain.push(node.to_string());
|
chain.push(node.to_string());
|
||||||
cursor = existing
|
cursor = existing.decision(&node).and_then(|m| m.replaced_by.clone());
|
||||||
.decision(&node)
|
|
||||||
.and_then(|m| m.replaced_by.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,7 @@ fn levenshtein(a: &str, b: &str) -> usize {
|
||||||
curr[0] = i + 1;
|
curr[0] = i + 1;
|
||||||
for (j, cb) in b.iter().enumerate() {
|
for (j, cb) in b.iter().enumerate() {
|
||||||
let cost = if ca == cb { 0 } else { 1 };
|
let cost = if ca == cb { 0 } else { 1 };
|
||||||
curr[j + 1] = (curr[j] + 1)
|
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
|
||||||
.min(prev[j + 1] + 1)
|
|
||||||
.min(prev[j] + cost);
|
|
||||||
}
|
}
|
||||||
std::mem::swap(&mut prev, &mut curr);
|
std::mem::swap(&mut prev, &mut curr);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,8 @@ pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
||||||
byte_offset += line.len();
|
byte_offset += line.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
let (yaml_end_excl, body_start) = yaml_end
|
||||||
LintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
.ok_or_else(|| LintError::MalformedFrontmatter("missing closing `---` line".to_string()))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let yaml = &after_open[..yaml_end_excl];
|
let yaml = &after_open[..yaml_end_excl];
|
||||||
let body = &after_open[body_start..];
|
let body = &after_open[body_start..];
|
||||||
|
|
|
||||||
|
|
@ -118,16 +118,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_bad_slugs() {
|
fn rejects_bad_slugs() {
|
||||||
for s in [
|
for s in [
|
||||||
"",
|
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||||
"-",
|
|
||||||
"-foo",
|
|
||||||
"foo-",
|
|
||||||
"Foo",
|
|
||||||
"foo_bar",
|
|
||||||
"foo bar",
|
|
||||||
"foo--bar",
|
|
||||||
"foo.bar",
|
|
||||||
"ä",
|
|
||||||
] {
|
] {
|
||||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||||
assert!(Slug::parse(s).is_err());
|
assert!(Slug::parse(s).is_err());
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,8 @@ struct EditTool {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for EditTool {
|
impl Tool for EditTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
let params: EditParams = serde_json::from_str(input_json).map_err(|e| {
|
let params: EditParams = serde_json::from_str(input_json)
|
||||||
ToolError::InvalidArgument(format!("invalid MemoryEdit input: {e}"))
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryEdit input: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
if params.old_string.is_empty() {
|
if params.old_string.is_empty() {
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
|
|
@ -60,7 +59,9 @@ impl Tool for EditTool {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = params.kind.resolve_path(&self.layout, params.slug.as_deref())?;
|
let path = params
|
||||||
|
.kind
|
||||||
|
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||||
|
|
||||||
let current_bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
let current_bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||||
|
|
||||||
pub use edit::edit_tool;
|
pub use edit::edit_tool;
|
||||||
pub use read::read_tool;
|
pub use read::read_tool;
|
||||||
pub use search::{knowledge_search_tool, memory_search_tool, SearchConfig};
|
pub use search::{SearchConfig, knowledge_search_tool, memory_search_tool};
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|
||||||
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
/// Kinds the memory tools accept as input. `Workflow` is intentionally
|
||||||
|
|
@ -71,13 +71,10 @@ impl MemoryToolKind {
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
let raw = slug.ok_or_else(|| {
|
let raw = slug.ok_or_else(|| {
|
||||||
ToolError::InvalidArgument(format!(
|
ToolError::InvalidArgument(format!("kind={} requires `slug`", other.as_str()))
|
||||||
"kind={} requires `slug`",
|
|
||||||
other.as_str()
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
let parsed = Slug::parse(raw)
|
let parsed =
|
||||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
|
Slug::parse(raw).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
|
||||||
Ok(match other {
|
Ok(match other {
|
||||||
Self::Decision => layout.decision_path(&parsed),
|
Self::Decision => layout.decision_path(&parsed),
|
||||||
Self::Request => layout.request_path(&parsed),
|
Self::Request => layout.request_path(&parsed),
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,12 @@ struct ReadTool {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ReadTool {
|
impl Tool for ReadTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
let params: ReadParams = serde_json::from_str(input_json).map_err(|e| {
|
let params: ReadParams = serde_json::from_str(input_json)
|
||||||
ToolError::InvalidArgument(format!("invalid MemoryRead input: {e}"))
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryRead input: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = params.kind.resolve_path(&self.layout, params.slug.as_deref())?;
|
let path = params
|
||||||
|
.kind
|
||||||
|
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||||
|
|
||||||
let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
let bytes = std::fs::read(&path).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => {
|
std::io::ErrorKind::NotFound => {
|
||||||
|
|
|
||||||
|
|
@ -116,9 +116,8 @@ struct KnowledgeSearchTool {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for MemorySearchTool {
|
impl Tool for MemorySearchTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
let params: MemorySearchParams = serde_json::from_str(input_json).map_err(|e| {
|
let params: MemorySearchParams = serde_json::from_str(input_json)
|
||||||
ToolError::InvalidArgument(format!("invalid MemorySearch input: {e}"))
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemorySearch input: {e}")))?;
|
||||||
})?;
|
|
||||||
let needle = validate_query(¶ms.query)?;
|
let needle = validate_query(¶ms.query)?;
|
||||||
|
|
||||||
let mut hits: Vec<MemoryHit> = Vec::new();
|
let mut hits: Vec<MemoryHit> = Vec::new();
|
||||||
|
|
@ -241,9 +240,7 @@ impl Tool for KnowledgeSearchTool {
|
||||||
|
|
||||||
fn validate_query(query: &str) -> Result<String, ToolError> {
|
fn validate_query(query: &str) -> Result<String, ToolError> {
|
||||||
if query.trim().is_empty() {
|
if query.trim().is_empty() {
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument("query must not be empty".into()));
|
||||||
"query must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(query.to_lowercase())
|
Ok(query.to_lowercase())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,12 @@ struct WriteTool {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for WriteTool {
|
impl Tool for WriteTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
let params: WriteParams = serde_json::from_str(input_json).map_err(|e| {
|
let params: WriteParams = serde_json::from_str(input_json)
|
||||||
ToolError::InvalidArgument(format!("invalid MemoryWrite input: {e}"))
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid MemoryWrite input: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = params.kind.resolve_path(&self.layout, params.slug.as_deref())?;
|
let path = params
|
||||||
|
.kind
|
||||||
|
.resolve_path(&self.layout, params.slug.as_deref())?;
|
||||||
|
|
||||||
let already_exists = path.exists();
|
let already_exists = path.exists();
|
||||||
let mode = if already_exists {
|
let mode = if already_exists {
|
||||||
|
|
@ -72,7 +73,11 @@ impl Tool for WriteTool {
|
||||||
|
|
||||||
let summary = format!(
|
let summary = format!(
|
||||||
"{} {}{}",
|
"{} {}{}",
|
||||||
if already_exists { "Overwrote" } else { "Created" },
|
if already_exists {
|
||||||
|
"Overwrote"
|
||||||
|
} else {
|
||||||
|
"Created"
|
||||||
|
},
|
||||||
path.display(),
|
path.display(),
|
||||||
warning_tail(&report),
|
warning_tail(&report),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,7 @@ impl WorkspaceLayout {
|
||||||
let knowledge = self.knowledge_dir();
|
let knowledge = self.knowledge_dir();
|
||||||
|
|
||||||
if let Ok(rel) = path.strip_prefix(&knowledge) {
|
if let Ok(rel) = path.strip_prefix(&knowledge) {
|
||||||
return Ok(Some(classify_kinded_md(
|
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
|
||||||
rel,
|
|
||||||
RecordKind::Knowledge,
|
|
||||||
path,
|
|
||||||
)?));
|
|
||||||
}
|
}
|
||||||
let rel = match path.strip_prefix(&memory) {
|
let rel = match path.strip_prefix(&memory) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,7 @@ fn main() {
|
||||||
let prompt_section = parsed
|
let prompt_section = parsed
|
||||||
.get("prompt")
|
.get("prompt")
|
||||||
.and_then(|v| v.as_table())
|
.and_then(|v| v.as_table())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| panic!("{} must contain a `[prompt]` table", toml_path.display()));
|
||||||
panic!(
|
|
||||||
"{} must contain a `[prompt]` table",
|
|
||||||
toml_path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut keys: Vec<String> = prompt_section.keys().cloned().collect();
|
let mut keys: Vec<String> = prompt_section.keys().cloned().collect();
|
||||||
keys.sort();
|
keys.sort();
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ impl CompactWorkerContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remaining_budget(&self) -> u64 {
|
fn remaining_budget(&self) -> u64 {
|
||||||
self.auto_read_budget.saturating_sub(self.auto_read_consumed)
|
self.auto_read_budget
|
||||||
|
.saturating_sub(self.auto_read_consumed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,16 @@ use tokio::sync::{broadcast, mpsc, oneshot};
|
||||||
|
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
|
use crate::ipc::server::SocketServer;
|
||||||
use crate::pod::{Pod, PodError, PodRunResult};
|
use crate::pod::{Pod, PodError, PodRunResult};
|
||||||
|
use crate::runtime::dir::RuntimeDir;
|
||||||
|
use crate::shared_state::{PodSharedState, PodStatus};
|
||||||
use crate::spawn::comm_tools::{
|
use crate::spawn::comm_tools::{
|
||||||
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
|
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
|
||||||
};
|
};
|
||||||
use crate::runtime::dir::RuntimeDir;
|
|
||||||
use crate::shared_state::{PodSharedState, PodStatus};
|
|
||||||
use crate::ipc::server::SocketServer;
|
|
||||||
use crate::spawn::tool::spawn_pod_tool;
|
|
||||||
use crate::spawn::registry::SpawnedPodRegistry;
|
use crate::spawn::registry::SpawnedPodRegistry;
|
||||||
use protocol::{ErrorCode, Event, Method, AlertLevel, AlertSource, RunResult, TurnResult};
|
use crate::spawn::tool::spawn_pod_tool;
|
||||||
|
use protocol::{AlertLevel, AlertSource, ErrorCode, Event, Method, RunResult, TurnResult};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PodHandle — client-facing, Clone-able
|
// PodHandle — client-facing, Clone-able
|
||||||
|
|
@ -215,11 +215,7 @@ impl PodController {
|
||||||
|
|
||||||
let alerter_for_worker = alerter.clone();
|
let alerter_for_worker = alerter.clone();
|
||||||
worker.on_warning(move |message| {
|
worker.on_warning(move |message| {
|
||||||
alerter_for_worker.alert(
|
alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Worker, message.to_owned());
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::Worker,
|
|
||||||
message.to_owned(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the builtin file-manipulation tools (Read / Write /
|
// Register the builtin file-manipulation tools (Read / Write /
|
||||||
|
|
@ -735,9 +731,15 @@ where
|
||||||
.map(|def| def().0.name)
|
.map(|def| def().0.name)
|
||||||
.collect();
|
.collect();
|
||||||
tool_names.extend(
|
tool_names.extend(
|
||||||
["SpawnPod", "SendToPod", "ReadPodOutput", "StopPod", "ListPods"]
|
[
|
||||||
.iter()
|
"SpawnPod",
|
||||||
.map(|s| (*s).into()),
|
"SendToPod",
|
||||||
|
"ReadPodOutput",
|
||||||
|
"StopPod",
|
||||||
|
"ListPods",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|s| (*s).into()),
|
||||||
);
|
);
|
||||||
protocol::Greeting {
|
protocol::Greeting {
|
||||||
pod_name: manifest.pod.name.clone(),
|
pod_name: manifest.pod.name.clone(),
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@
|
||||||
//! exposing the underlying mutable state.
|
//! exposing the underlying mutable state.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::tool::ToolOutput;
|
|
||||||
use llm_worker::interceptor::{
|
use llm_worker::interceptor::{
|
||||||
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
PostToolAction, PreRequestAction, PreToolAction, PromptAction, TurnEndAction,
|
||||||
};
|
};
|
||||||
|
use llm_worker::tool::ToolOutput;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
if !closures.is_empty() {
|
if !closures.is_empty() {
|
||||||
self.worker_mut().extend_history(closures);
|
self.worker_mut().extend_history(closures);
|
||||||
}
|
}
|
||||||
self.worker_mut().push_item(Item::system_message(system_note));
|
self.worker_mut()
|
||||||
|
.push_item(Item::system_message(system_note));
|
||||||
self.run(input).await
|
self.run(input).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,10 +85,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_orphans_returns_empty() {
|
fn no_orphans_returns_empty() {
|
||||||
let history = vec![
|
let history = vec![Item::user_message("hi"), Item::assistant_message("hello")];
|
||||||
Item::user_message("hi"),
|
|
||||||
Item::assistant_message("hello"),
|
|
||||||
];
|
|
||||||
let summary = interrupt_tool_result_summary();
|
let summary = interrupt_tool_result_summary();
|
||||||
assert!(orphan_tool_result_closures(&history, &summary).is_empty());
|
assert!(orphan_tool_result_closures(&history, &summary).is_empty());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use protocol::{Method, PodEvent, ScopeRule};
|
use protocol::{Method, PodEvent, ScopeRule};
|
||||||
|
|
||||||
use crate::spawn::comm_tools::connect_and_send;
|
|
||||||
use crate::runtime::dir::SpawnedPodRecord;
|
use crate::runtime::dir::SpawnedPodRecord;
|
||||||
use crate::runtime::scope_lock::{self, ScopeLockError};
|
use crate::runtime::scope_lock::{self, ScopeLockError};
|
||||||
|
use crate::spawn::comm_tools::connect_and_send;
|
||||||
use crate::spawn::registry::SpawnedPodRegistry;
|
use crate::spawn::registry::SpawnedPodRegistry;
|
||||||
|
|
||||||
/// Connect to `socket`, send a single `Method::PodEvent(event)`, and
|
/// Connect to `socket`, send a single `Method::PodEvent(event)`, and
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ use session_store::UsageRecord;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
|
use crate::compact::token_counter::total_tokens_impl;
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary,
|
AbortInfo, HookRegistry, PreRequestInfo, PromptSubmitInfo, ToolCallSummary, ToolResultSummary,
|
||||||
TurnEndInfo,
|
TurnEndInfo,
|
||||||
};
|
};
|
||||||
use crate::ipc::notify_buffer::{NotifyBuffer, format_notify};
|
use crate::ipc::notify_buffer::{NotifyBuffer, format_notify};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::compact::token_counter::total_tokens_impl;
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,10 @@ mod tests {
|
||||||
assert_eq!(drained.len(), CAPACITY);
|
assert_eq!(drained.len(), CAPACITY);
|
||||||
// Oldest 5 were dropped; first retained is msg5.
|
// Oldest 5 were dropped; first retained is msg5.
|
||||||
assert_eq!(drained[0].message, "msg5");
|
assert_eq!(drained[0].message, "msg5");
|
||||||
assert_eq!(drained[CAPACITY - 1].message, format!("msg{}", CAPACITY + 4));
|
assert_eq!(
|
||||||
|
drained[CAPACITY - 1].message,
|
||||||
|
format!("msg{}", CAPACITY + 4)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||||
pub use ipc::alerter::Alerter;
|
pub use ipc::alerter::Alerter;
|
||||||
pub use ipc::server::SocketServer;
|
pub use ipc::server::SocketServer;
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
AuthRef, ModelManifest, PodManifest, PodManifestConfig, PodMetaConfig, Scope, SchemeKind,
|
AuthRef, ModelManifest, PodManifest, PodManifestConfig, PodMetaConfig, SchemeKind, Scope,
|
||||||
};
|
};
|
||||||
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
pub use pod::{Pod, PodError, PodRunResult, apply_worker_manifest};
|
||||||
pub use prompt::catalog::{CatalogError, PodPrompt, PromptCatalog};
|
pub use prompt::catalog::{CatalogError, PodPrompt, PromptCatalog};
|
||||||
|
|
|
||||||
|
|
@ -171,10 +171,7 @@ async fn main() -> ExitCode {
|
||||||
// (e.g. the TUI's interactive `spawn` flow). Tab-separated so a
|
// (e.g. the TUI's interactive `spawn` flow). Tab-separated so a
|
||||||
// pod name with spaces still parses cleanly. Emit before the
|
// pod name with spaces still parses cleanly. Emit before the
|
||||||
// human line so a stderr-watching parent sees it first.
|
// human line so a stderr-watching parent sees it first.
|
||||||
eprintln!(
|
eprintln!("INSOMNIA-READY\t{pod_name}\t{}", socket_path.display());
|
||||||
"INSOMNIA-READY\t{pod_name}\t{}",
|
|
||||||
socket_path.display()
|
|
||||||
);
|
|
||||||
eprintln!("pod: {pod_name} listening on {:?}", socket_path);
|
eprintln!("pod: {pod_name} listening on {:?}", socket_path);
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,25 @@ use tracing::{info, warn};
|
||||||
|
|
||||||
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
|
use manifest::{PodManifest, PodManifestConfig, ResolveError, Scope, ScopeError, WorkerManifest};
|
||||||
|
|
||||||
use crate::prompt::agents_md::read_agents_md;
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use crate::hook::{
|
use crate::hook::{
|
||||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
||||||
PreRequestInfo, PreToolCall,
|
PreRequestInfo, PreToolCall,
|
||||||
};
|
};
|
||||||
use crate::ipc::alerter::Alerter;
|
use crate::ipc::alerter::Alerter;
|
||||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
|
||||||
use crate::ipc::interceptor::PodInterceptor;
|
use crate::ipc::interceptor::PodInterceptor;
|
||||||
use crate::prompt::loader::PromptLoader;
|
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||||
|
use crate::prompt::agents_md::read_agents_md;
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
|
use crate::prompt::loader::PromptLoader;
|
||||||
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::runtime::dir;
|
use crate::runtime::dir;
|
||||||
use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
|
||||||
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreRequestAction;
|
use llm_worker::interceptor::PreRequestAction;
|
||||||
|
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
/// Pre-LLM-request hook that records `history.len()` at send time into a
|
/// Pre-LLM-request hook that records `history.len()` at send time into a
|
||||||
/// shared `UsageTracker`. The on_usage callback later pairs this with the
|
/// shared `UsageTracker`. The on_usage callback later pairs this with the
|
||||||
|
|
@ -511,9 +511,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let usage_history_handle = compact_state
|
let usage_history_handle = compact_state.as_ref().map(|_| self.usage_history.clone());
|
||||||
.as_ref()
|
|
||||||
.map(|_| self.usage_history.clone());
|
|
||||||
|
|
||||||
let interceptor = PodInterceptor::new(
|
let interceptor = PodInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
|
|
@ -553,11 +551,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
let agents_md_read = read_agents_md(&self.pwd);
|
let agents_md_read = read_agents_md(&self.pwd);
|
||||||
for warning in agents_md_read.warnings {
|
for warning in agents_md_read.warnings {
|
||||||
if let Some(n) = alerter.as_ref() {
|
if let Some(n) = alerter.as_ref() {
|
||||||
n.alert(
|
n.alert(AlertLevel::Warn, AlertSource::AgentsMd, warning);
|
||||||
AlertLevel::Warn,
|
|
||||||
AlertSource::AgentsMd,
|
|
||||||
warning,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Resident-injection collection: only when memory is enabled in
|
// Resident-injection collection: only when memory is enabled in
|
||||||
|
|
@ -603,10 +597,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
/// Equivalent to `run(vec![Segment::text(s)])`. The dumb-client
|
/// Equivalent to `run(vec![Segment::text(s)])`. The dumb-client
|
||||||
/// counterpart of [`protocol::Method::run_text`]; primarily for
|
/// counterpart of [`protocol::Method::run_text`]; primarily for
|
||||||
/// tests and tools that have only a string in hand.
|
/// tests and tools that have only a string in hand.
|
||||||
pub async fn run_text(
|
pub async fn run_text(&mut self, s: impl Into<String>) -> Result<PodRunResult, PodError> {
|
||||||
&mut self,
|
|
||||||
s: impl Into<String>,
|
|
||||||
) -> Result<PodRunResult, PodError> {
|
|
||||||
self.run(vec![Segment::text(s)]).await
|
self.run(vec![Segment::text(s)]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -995,7 +986,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
.manifest
|
.manifest
|
||||||
.compaction
|
.compaction
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| (c.compact_auto_read_budget, c.compact_worker_max_input_tokens))
|
.map(|c| {
|
||||||
|
(
|
||||||
|
c.compact_auto_read_budget,
|
||||||
|
c.compact_worker_max_input_tokens,
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or((
|
.unwrap_or((
|
||||||
manifest::defaults::COMPACT_AUTO_READ_BUDGET,
|
manifest::defaults::COMPACT_AUTO_READ_BUDGET,
|
||||||
manifest::defaults::COMPACT_WORKER_MAX_INPUT_TOKENS,
|
manifest::defaults::COMPACT_WORKER_MAX_INPUT_TOKENS,
|
||||||
|
|
@ -1054,8 +1050,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
// Tools: read_file (shared scope, fresh tracker) + the three
|
// Tools: read_file (shared scope, fresh tracker) + the three
|
||||||
// compact-specific tools that populate `ctx`.
|
// compact-specific tools that populate `ctx`.
|
||||||
summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker));
|
summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker));
|
||||||
summary_worker
|
summary_worker.register_tool(mark_read_required_tool(scoped_fs.clone(), ctx.clone()));
|
||||||
.register_tool(mark_read_required_tool(scoped_fs.clone(), ctx.clone()));
|
|
||||||
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
summary_worker.register_tool(add_reference_tool(ctx.clone()));
|
||||||
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
summary_worker.register_tool(write_summary_tool(ctx.clone()));
|
||||||
|
|
||||||
|
|
@ -1092,10 +1087,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(prompt) = nudge {
|
if let Some(prompt) = nudge {
|
||||||
let _ = locked_worker
|
let _ = locked_worker.run(prompt).await.map_err(PodError::Worker)?;
|
||||||
.run(prompt)
|
|
||||||
.await
|
|
||||||
.map_err(PodError::Worker)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
let final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
|
||||||
|
|
@ -1154,7 +1146,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
||||||
// Build new history: [summary, ...auto-read, references, ...retained].
|
// Build new history: [summary, ...auto-read, references, ...retained].
|
||||||
let mut new_history = Vec::with_capacity(
|
let mut new_history = Vec::with_capacity(
|
||||||
1 + auto_read_messages.len() + reference_message.is_some() as usize
|
1 + auto_read_messages.len()
|
||||||
|
+ reference_message.is_some() as usize
|
||||||
+ retained_items.len(),
|
+ retained_items.len(),
|
||||||
);
|
);
|
||||||
new_history.push(Item::system_message(format!(
|
new_history.push(Item::system_message(format!(
|
||||||
|
|
@ -1456,9 +1449,7 @@ fn build_summary_input(items: &[Item], default_refs: &[PathBuf]) -> String {
|
||||||
}
|
}
|
||||||
out.push_str("## Conversation\n");
|
out.push_str("## Conversation\n");
|
||||||
out.push_str(&build_summary_prompt(items));
|
out.push_str(&build_summary_prompt(items));
|
||||||
out.push_str(
|
out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text.");
|
||||||
"\n\nWhen you are done, call `write_summary` with the final 5-section text.",
|
|
||||||
);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1579,10 +1570,8 @@ fn current_pwd() -> Result<PathBuf, PodError> {
|
||||||
pwd: PathBuf::from("."),
|
pwd: PathBuf::from("."),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
cwd.canonicalize().map_err(|source| PodError::InvalidPwd {
|
cwd.canonicalize()
|
||||||
pwd: cwd,
|
.map_err(|source| PodError::InvalidPwd { pwd: cwd, source })
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -310,10 +310,7 @@ impl PromptCatalog {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render `PodPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`.
|
/// Render `PodPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`.
|
||||||
pub fn working_boundaries_section(
|
pub fn working_boundaries_section(&self, scope_summary: &str) -> Result<String, CatalogError> {
|
||||||
&self,
|
|
||||||
scope_summary: &str,
|
|
||||||
) -> Result<String, CatalogError> {
|
|
||||||
self.render(
|
self.render(
|
||||||
PodPrompt::WorkingBoundariesSection,
|
PodPrompt::WorkingBoundariesSection,
|
||||||
single("scope_summary", scope_summary),
|
single("scope_summary", scope_summary),
|
||||||
|
|
@ -343,8 +340,7 @@ fn single(key: &'static str, value: &str) -> Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_builtin_pack() -> Result<HashMap<String, String>, CatalogError> {
|
fn parse_builtin_pack() -> Result<HashMap<String, String>, CatalogError> {
|
||||||
let parsed: PackFile =
|
let parsed: PackFile = toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?;
|
||||||
toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?;
|
|
||||||
Ok(parsed.prompt)
|
Ok(parsed.prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,7 @@ use std::path::{Path, PathBuf};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
static BUILTIN_PROMPTS: Dir<'static> =
|
static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
||||||
include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
|
||||||
|
|
||||||
const PREFIX_INSOMNIA: &str = "$insomnia";
|
const PREFIX_INSOMNIA: &str = "$insomnia";
|
||||||
const PREFIX_USER: &str = "$user";
|
const PREFIX_USER: &str = "$user";
|
||||||
|
|
@ -190,10 +189,12 @@ impl PromptLoader {
|
||||||
}
|
}
|
||||||
if let Some(prefix) = trimmed.strip_prefix('$') {
|
if let Some(prefix) = trimmed.strip_prefix('$') {
|
||||||
let (prefix_name, rest) =
|
let (prefix_name, rest) =
|
||||||
prefix.split_once('/').ok_or_else(|| LoaderError::InvalidRef {
|
prefix
|
||||||
raw: raw.to_string(),
|
.split_once('/')
|
||||||
reason: "prefix must be followed by '/'".into(),
|
.ok_or_else(|| LoaderError::InvalidRef {
|
||||||
})?;
|
raw: raw.to_string(),
|
||||||
|
reason: "prefix must be followed by '/'".into(),
|
||||||
|
})?;
|
||||||
let prefix = parse_prefix(raw, prefix_name)?;
|
let prefix = parse_prefix(raw, prefix_name)?;
|
||||||
let path = normalize_path(raw, rest)?;
|
let path = normalize_path(raw, rest)?;
|
||||||
Ok(PromptRef { prefix, path })
|
Ok(PromptRef { prefix, path })
|
||||||
|
|
@ -293,10 +294,7 @@ fn load_from_dir(dir: &Path, reference: &PromptRef) -> Result<String, LoaderErro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_from_include_dir(
|
fn load_from_include_dir(dir: &Dir<'static>, reference: &PromptRef) -> Result<String, LoaderError> {
|
||||||
dir: &Dir<'static>,
|
|
||||||
reference: &PromptRef,
|
|
||||||
) -> Result<String, LoaderError> {
|
|
||||||
let path = format!("{}.md", reference.path);
|
let path = format!("{}.md", reference.path);
|
||||||
dir.get_file(&path)
|
dir.get_file(&path)
|
||||||
.and_then(|f| f.contents_utf8())
|
.and_then(|f| f.contents_utf8())
|
||||||
|
|
@ -349,7 +347,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_file_is_hard_error() {
|
fn missing_file_is_hard_error() {
|
||||||
let loader = PromptLoader::builtins_only();
|
let loader = PromptLoader::builtins_only();
|
||||||
let err = loader.resolve("$insomnia/definitely-missing", None).unwrap_err();
|
let err = loader
|
||||||
|
.resolve("$insomnia/definitely-missing", None)
|
||||||
|
.unwrap_err();
|
||||||
assert!(matches!(err, LoaderError::NotFound { .. }));
|
assert!(matches!(err, LoaderError::NotFound { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,7 +380,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unqualified_ref_resolves_relative_to_current() {
|
fn unqualified_ref_resolves_relative_to_current() {
|
||||||
let loader = PromptLoader::builtins_only();
|
let loader = PromptLoader::builtins_only();
|
||||||
let current = loader.parse_ref("$insomnia/common/tool-usage", None).unwrap();
|
let current = loader
|
||||||
|
.parse_ref("$insomnia/common/tool-usage", None)
|
||||||
|
.unwrap();
|
||||||
// Sibling lookup under the same prefix and directory.
|
// Sibling lookup under the same prefix and directory.
|
||||||
let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap();
|
let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap();
|
||||||
assert_eq!(sibling.to_qualified_string(), "$insomnia/common/workspace");
|
assert_eq!(sibling.to_qualified_string(), "$insomnia/common/workspace");
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ use minijinja::value::Value;
|
||||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
|
||||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||||
|
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum SystemPromptError {
|
pub enum SystemPromptError {
|
||||||
|
|
@ -55,10 +55,7 @@ impl SystemPromptTemplate {
|
||||||
/// Parse the instruction asset referenced by `instruction_ref`
|
/// Parse the instruction asset referenced by `instruction_ref`
|
||||||
/// using the supplied [`PromptLoader`]. The reference is resolved
|
/// using the supplied [`PromptLoader`]. The reference is resolved
|
||||||
/// at parse time so syntax errors surface immediately.
|
/// at parse time so syntax errors surface immediately.
|
||||||
pub fn parse(
|
pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result<Self, SystemPromptError> {
|
||||||
instruction_ref: &str,
|
|
||||||
loader: PromptLoader,
|
|
||||||
) -> Result<Self, SystemPromptError> {
|
|
||||||
let root_ref = loader
|
let root_ref = loader
|
||||||
.parse_ref(instruction_ref, None)
|
.parse_ref(instruction_ref, None)
|
||||||
.map_err(SystemPromptError::LoaderResolve)?;
|
.map_err(SystemPromptError::LoaderResolve)?;
|
||||||
|
|
@ -75,9 +72,7 @@ impl SystemPromptTemplate {
|
||||||
// The joined name is then looked up via `set_loader` below.
|
// The joined name is then looked up via `set_loader` below.
|
||||||
let loader_for_join = loader.clone();
|
let loader_for_join = loader.clone();
|
||||||
env.set_path_join_callback(move |name, parent| {
|
env.set_path_join_callback(move |name, parent| {
|
||||||
let parent_ref = loader_for_join
|
let parent_ref = loader_for_join.parse_ref(parent, None).ok();
|
||||||
.parse_ref(parent, None)
|
|
||||||
.ok();
|
|
||||||
match loader_for_join.parse_ref(name, parent_ref.as_ref()) {
|
match loader_for_join.parse_ref(name, parent_ref.as_ref()) {
|
||||||
Ok(r) => r.to_qualified_string().into(),
|
Ok(r) => r.to_qualified_string().into(),
|
||||||
// Propagate the raw name on error so set_loader surfaces
|
// Propagate the raw name on error so set_loader surfaces
|
||||||
|
|
@ -93,7 +88,10 @@ impl SystemPromptTemplate {
|
||||||
.map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?;
|
.map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?;
|
||||||
match loader_for_src.load(&reference) {
|
match loader_for_src.load(&reference) {
|
||||||
Ok(source) => Ok(Some(source)),
|
Ok(source) => Ok(Some(source)),
|
||||||
Err(e) => Err(minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string())),
|
Err(e) => Err(minijinja::Error::new(
|
||||||
|
ErrorKind::TemplateNotFound,
|
||||||
|
e.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -459,7 +457,9 @@ mod tests {
|
||||||
let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap();
|
let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap();
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let scope = build_scope(dir.path());
|
let scope = build_scope(dir.path());
|
||||||
let err = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap_err();
|
let err = tmpl
|
||||||
|
.render(&ctx(dir.path(), &scope, vec![], None))
|
||||||
|
.unwrap_err();
|
||||||
assert!(matches!(err, SystemPromptError::Render(_)));
|
assert!(matches!(err, SystemPromptError::Render(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,7 @@ impl RuntimeDir {
|
||||||
/// Write `spawned_pods.json` atomically. The entries are the full
|
/// Write `spawned_pods.json` atomically. The entries are the full
|
||||||
/// set of spawned children known to this Pod — callers pass the
|
/// set of spawned children known to this Pod — callers pass the
|
||||||
/// replacement list, no incremental merge.
|
/// replacement list, no incremental merge.
|
||||||
pub async fn write_spawned_pods(
|
pub async fn write_spawned_pods(&self, records: &[SpawnedPodRecord]) -> Result<(), io::Error> {
|
||||||
&self,
|
|
||||||
records: &[SpawnedPodRecord],
|
|
||||||
) -> Result<(), io::Error> {
|
|
||||||
let json = serde_json::to_vec_pretty(records).map_err(io::Error::other)?;
|
let json = serde_json::to_vec_pretty(records).map_err(io::Error::other)?;
|
||||||
atomic_write(&self.path.join("spawned_pods.json"), &json).await
|
atomic_write(&self.path.join("spawned_pods.json"), &json).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,10 +206,7 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if rule.permission != Permission::Write {
|
if rule.permission != Permission::Write {
|
||||||
return alloc
|
return alloc.scope_allow.iter().any(|r| covers_fully(r, rule));
|
||||||
.scope_allow
|
|
||||||
.iter()
|
|
||||||
.any(|r| covers_fully(r, rule));
|
|
||||||
}
|
}
|
||||||
let covered = alloc
|
let covered = alloc
|
||||||
.scope_allow
|
.scope_allow
|
||||||
|
|
@ -244,7 +241,11 @@ pub fn find_conflict_owner(
|
||||||
if rule.permission != Permission::Write {
|
if rule.permission != Permission::Write {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for alloc in lock.allocations.iter().filter(|a| a.delegated_from.is_none()) {
|
for alloc in lock
|
||||||
|
.allocations
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.delegated_from.is_none())
|
||||||
|
{
|
||||||
if let Some(owner) = find_conflict_in_subtree(lock, alloc, rule) {
|
if let Some(owner) = find_conflict_in_subtree(lock, alloc, rule) {
|
||||||
if Some(owner.as_str()) == exempt {
|
if Some(owner.as_str()) == exempt {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -526,18 +527,12 @@ pub enum ScopeLockError {
|
||||||
#[error("pod name `{0}` is already registered")]
|
#[error("pod name `{0}` is already registered")]
|
||||||
DuplicatePodName(String),
|
DuplicatePodName(String),
|
||||||
#[error("requested scope `{}` conflicts with pod `{competitor}`", .rule.target.display())]
|
#[error("requested scope `{}` conflicts with pod `{competitor}`", .rule.target.display())]
|
||||||
WriteConflict {
|
WriteConflict { competitor: String, rule: ScopeRule },
|
||||||
competitor: String,
|
|
||||||
rule: ScopeRule,
|
|
||||||
},
|
|
||||||
#[error(
|
#[error(
|
||||||
"requested scope `{}` is not within spawner `{spawner}`'s effective scope",
|
"requested scope `{}` is not within spawner `{spawner}`'s effective scope",
|
||||||
.rule.target.display()
|
.rule.target.display()
|
||||||
)]
|
)]
|
||||||
NotSubset {
|
NotSubset { spawner: String, rule: ScopeRule },
|
||||||
spawner: String,
|
|
||||||
rule: ScopeRule,
|
|
||||||
},
|
|
||||||
#[error("pod `{0}` is not registered")]
|
#[error("pod `{0}` is not registered")]
|
||||||
UnknownPod(String),
|
UnknownPod(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,7 @@ struct NameInput {
|
||||||
// SendToPod
|
// SendToPod
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SEND_TO_POD_DESCRIPTION: &str =
|
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned Pod. The spawned Pod \
|
||||||
"Send a text message to a previously spawned Pod. The spawned Pod \
|
|
||||||
processes it as a user turn. Fails if the Pod is already executing a \
|
processes it as a user turn. Fails if the Pod is already executing a \
|
||||||
turn — retry after it finishes. Does not wait for the turn to complete; \
|
turn — retry after it finishes. Does not wait for the turn to complete; \
|
||||||
use `ReadPodOutput` to fetch results afterwards.";
|
use `ReadPodOutput` to fetch results afterwards.";
|
||||||
|
|
@ -109,8 +108,7 @@ pub fn send_to_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
// ReadPodOutput
|
// ReadPodOutput
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const READ_POD_OUTPUT_DESCRIPTION: &str =
|
const READ_POD_OUTPUT_DESCRIPTION: &str = "Fetch new assistant text from a spawned Pod since the last read. \
|
||||||
"Fetch new assistant text from a spawned Pod since the last read. \
|
|
||||||
Uses an internal cursor per-Pod so consecutive calls return only \
|
Uses an internal cursor per-Pod so consecutive calls return only \
|
||||||
newly-produced output. Returns the Pod's current status and the new \
|
newly-produced output. Returns the Pod's current status and the new \
|
||||||
text, or reports `stopped` if the Pod can no longer be reached.";
|
text, or reports `stopped` if the Pod can no longer be reached.";
|
||||||
|
|
@ -122,9 +120,8 @@ struct ReadPodOutputTool {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for ReadPodOutputTool {
|
impl Tool for ReadPodOutputTool {
|
||||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||||
let input: NameInput = serde_json::from_str(input_json).map_err(|e| {
|
let input: NameInput = serde_json::from_str(input_json)
|
||||||
ToolError::InvalidArgument(format!("invalid ReadPodOutput input: {e}"))
|
.map_err(|e| ToolError::InvalidArgument(format!("invalid ReadPodOutput input: {e}")))?;
|
||||||
})?;
|
|
||||||
let record = self
|
let record = self
|
||||||
.registry
|
.registry
|
||||||
.get(&input.name)
|
.get(&input.name)
|
||||||
|
|
@ -154,7 +151,10 @@ impl Tool for ReadPodOutputTool {
|
||||||
format!("pod `{}` running; no new assistant text", input.name)
|
format!("pod `{}` running; no new assistant text", input.name)
|
||||||
} else {
|
} else {
|
||||||
let lines = new_text.lines().count();
|
let lines = new_text.lines().count();
|
||||||
format!("pod `{}`: {lines} new line(s) of assistant text", input.name)
|
format!(
|
||||||
|
"pod `{}`: {lines} new line(s) of assistant text",
|
||||||
|
input.name
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let content = if new_text.is_empty() {
|
let content = if new_text.is_empty() {
|
||||||
None
|
None
|
||||||
|
|
@ -183,8 +183,7 @@ pub fn read_pod_output_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition
|
||||||
// StopPod
|
// StopPod
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const STOP_POD_DESCRIPTION: &str =
|
const STOP_POD_DESCRIPTION: &str = "Terminate a spawned Pod and reclaim the delegated scope. The Pod \
|
||||||
"Terminate a spawned Pod and reclaim the delegated scope. The Pod \
|
|
||||||
receives `Shutdown`; its scope entry is released in the machine-wide \
|
receives `Shutdown`; its scope entry is released in the machine-wide \
|
||||||
registry so the spawner can spawn a new Pod over the same paths.";
|
registry so the spawner can spawn a new Pod over the same paths.";
|
||||||
|
|
||||||
|
|
@ -247,8 +246,7 @@ pub fn stop_pod_tool(registry: Arc<SpawnedPodRegistry>) -> ToolDefinition {
|
||||||
// ListPods
|
// ListPods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const LIST_PODS_DESCRIPTION: &str =
|
const LIST_PODS_DESCRIPTION: &str = "List all Pods spawned by this Pod along with their reachability \
|
||||||
"List all Pods spawned by this Pod along with their reachability \
|
|
||||||
status (`alive` / `stopped`) and the scope each was granted.";
|
status (`alive` / `stopped`) and the scope each was granted.";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -364,9 +362,9 @@ async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRu
|
||||||
input: vec![protocol::Segment::text(input)],
|
input: vec![protocol::Segment::text(input)],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| SendRunError::Io("write timed out".into()))?
|
.map_err(|_| SendRunError::Io("write timed out".into()))?
|
||||||
.map_err(|e| SendRunError::Io(format!("write: {e}")))?;
|
.map_err(|e| SendRunError::Io(format!("write: {e}")))?;
|
||||||
loop {
|
loop {
|
||||||
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
let event = tokio::time::timeout(SOCKET_OP_TIMEOUT, reader.next::<Event>())
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ impl SpawnedPodRegistry {
|
||||||
pub async fn add(&self, record: SpawnedPodRecord) -> io::Result<()> {
|
pub async fn add(&self, record: SpawnedPodRecord) -> io::Result<()> {
|
||||||
let mut records = self.records.lock().await;
|
let mut records = self.records.lock().await;
|
||||||
records.push(record);
|
records.push(record);
|
||||||
self.runtime_dir.write_spawned_pods(records.as_slice()).await
|
self.runtime_dir
|
||||||
|
.write_spawned_pods(records.as_slice())
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a record by pod name. Cloned so callers can drop the lock.
|
/// Look up a record by pod name. Cloned so callers can drop the lock.
|
||||||
|
|
@ -67,7 +69,9 @@ impl SpawnedPodRegistry {
|
||||||
let mut records = self.records.lock().await;
|
let mut records = self.records.lock().await;
|
||||||
let idx = records.iter().position(|r| r.pod_name == pod_name);
|
let idx = records.iter().position(|r| r.pod_name == pod_name);
|
||||||
let removed = idx.map(|i| records.remove(i));
|
let removed = idx.map(|i| records.remove(i));
|
||||||
self.runtime_dir.write_spawned_pods(records.as_slice()).await?;
|
self.runtime_dir
|
||||||
|
.write_spawned_pods(records.as_slice())
|
||||||
|
.await?;
|
||||||
removed
|
removed
|
||||||
};
|
};
|
||||||
self.cursors.lock().await.remove(pod_name);
|
self.cursors.lock().await.remove(pod_name);
|
||||||
|
|
|
||||||
|
|
@ -294,9 +294,9 @@ impl SpawnPodTool {
|
||||||
.stderr(Stdio::from(stderr_file))
|
.stderr(Stdio::from(stderr_file))
|
||||||
.process_group(0);
|
.process_group(0);
|
||||||
|
|
||||||
let child = cmd
|
let child = cmd.spawn().map_err(|e| {
|
||||||
.spawn()
|
ToolError::ExecutionFailed(format!("failed to spawn `{pod_command}`: {e}"))
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("failed to spawn `{pod_command}`: {e}")))?;
|
})?;
|
||||||
|
|
||||||
// Default `kill_on_drop = false` keeps the process alive after
|
// Default `kill_on_drop = false` keeps the process alive after
|
||||||
// the `Child` is dropped. We intentionally do not `.wait()` —
|
// the `Child` is dropped. We intentionally do not `.wait()` —
|
||||||
|
|
@ -498,7 +498,10 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
|
||||||
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
|
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
|
||||||
assert_eq!(parsed.model.base_url.as_deref(), Some("https://example.test"));
|
assert_eq!(
|
||||||
|
parsed.model.base_url.as_deref(),
|
||||||
|
Some("https://example.test")
|
||||||
|
);
|
||||||
let file = match parsed.model.auth {
|
let file = match parsed.model.auth {
|
||||||
Some(AuthRef::ApiKey { file, .. }) => file,
|
Some(AuthRef::ApiKey { file, .. }) => file,
|
||||||
_ => panic!("expected ApiKey"),
|
_ => panic!("expected ApiKey"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::{Stream, StreamExt};
|
use futures::{Stream, StreamExt};
|
||||||
|
|
@ -169,10 +169,7 @@ async fn run_updates_shared_state_to_idle_after_completion() {
|
||||||
let pod = make_pod(client).await;
|
let pod = make_pod(client).await;
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||||
.send(Method::run_text("Hello"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Wait for the run to complete
|
// Wait for the run to complete
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
@ -186,10 +183,7 @@ async fn run_populates_history() {
|
||||||
let pod = make_pod(client).await;
|
let pod = make_pod(client).await;
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||||
.send(Method::run_text("Hello"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
|
@ -207,10 +201,7 @@ async fn events_are_broadcast() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||||
.send(Method::run_text("Hello"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut saw_turn_start = false;
|
let mut saw_turn_start = false;
|
||||||
let mut saw_text_delta = false;
|
let mut saw_text_delta = false;
|
||||||
|
|
@ -258,16 +249,10 @@ async fn double_run_returns_error() {
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
// Send first run
|
// Send first run
|
||||||
handle
|
handle.send(Method::run_text("first")).await.unwrap();
|
||||||
.send(Method::run_text("first"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Immediately send second run (should get error)
|
// Immediately send second run (should get error)
|
||||||
handle
|
handle.send(Method::run_text("second")).await.unwrap();
|
||||||
.send(Method::run_text("second"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Look for the error event
|
// Look for the error event
|
||||||
let mut saw_already_running = false;
|
let mut saw_already_running = false;
|
||||||
|
|
@ -410,8 +395,14 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|i| i.as_text().map(|s| s.to_string()))
|
.find_map(|i| i.as_text().map(|s| s.to_string()))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
assert!(user_text.contains("see line1\nline2 thanks"), "got: {user_text:?}");
|
assert!(
|
||||||
assert!(!user_text.contains("[Clipboard"), "label must not leak: {user_text:?}");
|
user_text.contains("see line1\nline2 thanks"),
|
||||||
|
"got: {user_text:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!user_text.contains("[Clipboard"),
|
||||||
|
"label must not leak: {user_text:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -424,12 +415,11 @@ async fn run_with_unresolved_segment_emits_alert_and_placeholder() {
|
||||||
|
|
||||||
let segments = vec![
|
let segments = vec![
|
||||||
protocol::Segment::text("look at "),
|
protocol::Segment::text("look at "),
|
||||||
protocol::Segment::FileRef { path: "src/lib.rs".into() },
|
protocol::Segment::FileRef {
|
||||||
|
path: "src/lib.rs".into(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
handle
|
handle.send(Method::Run { input: segments }).await.unwrap();
|
||||||
.send(Method::Run { input: segments })
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||||
let mut saw_alert_for_file_ref = false;
|
let mut saw_alert_for_file_ref = false;
|
||||||
|
|
@ -527,10 +517,7 @@ async fn notify_while_running_does_not_emit_already_running_error() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("start")).await.unwrap();
|
||||||
.send(Method::run_text("start"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Notify {
|
.send(Method::Notify {
|
||||||
message: "ping".into(),
|
message: "ping".into(),
|
||||||
|
|
@ -591,10 +578,7 @@ async fn socket_run_receives_events() {
|
||||||
let mut writer = JsonLineWriter::new(writer);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
|
|
||||||
// Send run method via socket
|
// Send run method via socket
|
||||||
writer
|
writer.write(&Method::run_text("Hello")).await.unwrap();
|
||||||
.write(&Method::run_text("Hello"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Collect events
|
// Collect events
|
||||||
let mut saw_turn_start = false;
|
let mut saw_turn_start = false;
|
||||||
|
|
@ -739,10 +723,7 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("hello")).await.unwrap();
|
||||||
.send(Method::run_text("hello"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Wait for the partial text_delta to confirm the first stream is
|
// Wait for the partial text_delta to confirm the first stream is
|
||||||
// live before we pause.
|
// live before we pause.
|
||||||
|
|
@ -794,10 +775,7 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||||
// (partial text is not committed), no orphan tool_use.
|
// (partial text is not committed), no orphan tool_use.
|
||||||
let history_json = handle.shared_state.history_json();
|
let history_json = handle.shared_state.history_json();
|
||||||
let items: Vec<serde_json::Value> = serde_json::from_str(&history_json).unwrap();
|
let items: Vec<serde_json::Value> = serde_json::from_str(&history_json).unwrap();
|
||||||
let roles: Vec<&str> = items
|
let roles: Vec<&str> = items.iter().filter_map(|i| i["role"].as_str()).collect();
|
||||||
.iter()
|
|
||||||
.filter_map(|i| i["role"].as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
roles,
|
roles,
|
||||||
vec!["user", "assistant"],
|
vec!["user", "assistant"],
|
||||||
|
|
@ -850,10 +828,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle.send(Method::run_text("first")).await.unwrap();
|
||||||
.send(Method::run_text("first"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Wait for ToolCallDone — the ToolCall is committed to history
|
// Wait for ToolCallDone — the ToolCall is committed to history
|
||||||
// right before the Worker enters tool execution and pends.
|
// right before the Worker enters tool execution and pends.
|
||||||
|
|
@ -883,10 +858,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
// New user input while Paused → controller routes to
|
// New user input while Paused → controller routes to
|
||||||
// `Pod::interrupt_and_run`, which closes the orphan + injects a
|
// `Pod::interrupt_and_run`, which closes the orphan + injects a
|
||||||
// system note before the fresh user message.
|
// system note before the fresh user message.
|
||||||
handle
|
handle.send(Method::run_text("new request")).await.unwrap();
|
||||||
.send(Method::run_text("new request"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
|
drain_until(&mut rx, std::time::Duration::from_secs(2), |e| matches!(
|
||||||
e,
|
e,
|
||||||
|
|
@ -925,9 +897,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
saw_interruption_note = true;
|
saw_interruption_note = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
llm_worker::Item::Message { role, content, .. }
|
llm_worker::Item::Message { role, content, .. } if *role == llm_worker::Role::User => {
|
||||||
if *role == llm_worker::Role::User =>
|
|
||||||
{
|
|
||||||
let text: String = content.iter().map(|p| p.as_text()).collect();
|
let text: String = content.iter().map(|p| p.as_text()).collect();
|
||||||
if text.contains("new request") {
|
if text.contains("new request") {
|
||||||
saw_new_user = true;
|
saw_new_user = true;
|
||||||
|
|
@ -952,10 +922,10 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
// Also confirm the closure chain is ordered: tool_result for the
|
// Also confirm the closure chain is ordered: tool_result for the
|
||||||
// orphan precedes the system note, which precedes the new user
|
// orphan precedes the system note, which precedes the new user
|
||||||
// message.
|
// message.
|
||||||
let idx = |pred: &dyn Fn(&llm_worker::Item) -> bool| {
|
let idx = |pred: &dyn Fn(&llm_worker::Item) -> bool| items.iter().position(pred).unwrap();
|
||||||
items.iter().position(pred).unwrap()
|
let tool_result_idx = idx(
|
||||||
};
|
&|i| matches!(i, llm_worker::Item::ToolResult { call_id, .. } if call_id == "call_orphan"),
|
||||||
let tool_result_idx = idx(&|i| matches!(i, llm_worker::Item::ToolResult { call_id, .. } if call_id == "call_orphan"));
|
);
|
||||||
let sys_idx = idx(&|i| match i {
|
let sys_idx = idx(&|i| match i {
|
||||||
llm_worker::Item::Message {
|
llm_worker::Item::Message {
|
||||||
role: llm_worker::Role::System,
|
role: llm_worker::Role::System,
|
||||||
|
|
@ -980,7 +950,12 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
.contains("new request"),
|
.contains("new request"),
|
||||||
_ => false,
|
_ => false,
|
||||||
});
|
});
|
||||||
assert!(tool_result_idx < sys_idx, "tool_result must precede system note");
|
assert!(
|
||||||
assert!(sys_idx < user_idx, "system note must precede new user message");
|
tool_result_idx < sys_idx,
|
||||||
|
"tool_result must precede system note"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sys_idx < user_idx,
|
||||||
|
"system note must precede new user message"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ use std::sync::{Arc, LazyLock, Mutex};
|
||||||
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
use llm_worker::llm_client::types::{ContentPart, Item, Role};
|
||||||
use llm_worker::tool::ToolOutput;
|
use llm_worker::tool::ToolOutput;
|
||||||
use manifest::{Permission, ScopeRule};
|
use manifest::{Permission, ScopeRule};
|
||||||
|
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
||||||
|
use pod::runtime::scope_lock::{self, LockFileGuard};
|
||||||
use pod::spawn::comm_tools::{
|
use pod::spawn::comm_tools::{
|
||||||
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
|
list_pods_tool, read_pod_output_tool, send_to_pod_tool, stop_pod_tool,
|
||||||
};
|
};
|
||||||
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
|
||||||
use pod::runtime::scope_lock::{self, LockFileGuard};
|
|
||||||
use pod::spawn::registry::SpawnedPodRegistry;
|
use pod::spawn::registry::SpawnedPodRegistry;
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use protocol::{ErrorCode, Event, Greeting, Method};
|
use protocol::{ErrorCode, Event, Greeting, Method};
|
||||||
|
|
@ -211,7 +211,11 @@ async fn send_to_pod_delivers_run_method() {
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
let input = json!({ "name": "child", "message": "hello there" }).to_string();
|
let input = json!({ "name": "child", "message": "hello there" }).to_string();
|
||||||
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
||||||
assert!(output.summary.contains("child"), "summary: {}", output.summary);
|
assert!(
|
||||||
|
output.summary.contains("child"),
|
||||||
|
"summary: {}",
|
||||||
|
output.summary
|
||||||
|
);
|
||||||
|
|
||||||
let method = received.await.unwrap().expect("expected a method");
|
let method = received.await.unwrap().expect("expected a method");
|
||||||
match method {
|
match method {
|
||||||
|
|
@ -292,7 +296,11 @@ async fn read_pod_output_returns_new_assistant_text_then_empty_on_second_call()
|
||||||
|
|
||||||
// Cursor now points past all items — second call returns no new text.
|
// Cursor now points past all items — second call returns no new text.
|
||||||
let second: ToolOutput = tool.execute(&input).await.unwrap();
|
let second: ToolOutput = tool.execute(&input).await.unwrap();
|
||||||
assert!(second.content.is_none(), "unexpected content: {:?}", second.content);
|
assert!(
|
||||||
|
second.content.is_none(),
|
||||||
|
"unexpected content: {:?}",
|
||||||
|
second.content
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
second.summary.contains("no new assistant text"),
|
second.summary.contains("no new assistant text"),
|
||||||
"summary: {}",
|
"summary: {}",
|
||||||
|
|
@ -451,6 +459,10 @@ async fn list_pods_empty_when_nothing_registered() {
|
||||||
let def = list_pods_tool(registry);
|
let def = list_pods_tool(registry);
|
||||||
let (_meta, tool) = def();
|
let (_meta, tool) = def();
|
||||||
let output: ToolOutput = tool.execute("{}").await.unwrap();
|
let output: ToolOutput = tool.execute("{}").await.unwrap();
|
||||||
assert!(output.summary.contains("no spawned pods"), "{}", output.summary);
|
assert!(
|
||||||
|
output.summary.contains("no spawned pods"),
|
||||||
|
"{}",
|
||||||
|
output.summary
|
||||||
|
);
|
||||||
assert!(output.content.is_none());
|
assert!(output.content.is_none());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,7 @@ fn clear_runtime_dir() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a single connection, read one `Method`, and return it.
|
/// Accept a single connection, read one `Method`, and return it.
|
||||||
fn accept_one_method(
|
fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<Method>> {
|
||||||
listener: UnixListener,
|
|
||||||
) -> tokio::task::JoinHandle<Option<Method>> {
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let (stream, _) = listener.accept().await.ok()?;
|
let (stream, _) = listener.accept().await.ok()?;
|
||||||
let (reader, _writer) = stream.into_split();
|
let (reader, _writer) = stream.into_split();
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ use llm_worker::tool::{ToolError, ToolOutput};
|
||||||
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, ScopeRule};
|
use manifest::{AuthRef, ModelManifest, Permission, SchemeKind, ScopeRule};
|
||||||
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord};
|
||||||
use pod::runtime::scope_lock::{self, LockFileGuard};
|
use pod::runtime::scope_lock::{self, LockFileGuard};
|
||||||
use pod::spawn::tool::spawn_pod_tool;
|
|
||||||
use pod::spawn::registry::SpawnedPodRegistry;
|
use pod::spawn::registry::SpawnedPodRegistry;
|
||||||
|
use pod::spawn::tool::spawn_pod_tool;
|
||||||
use protocol::Method;
|
use protocol::Method;
|
||||||
use protocol::stream::JsonLineReader;
|
use protocol::stream::JsonLineReader;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -99,9 +99,7 @@ async fn bind_mock_pod_socket(runtime_base: &Path, pod_name: &str) -> (PathBuf,
|
||||||
/// `Method` line, then returns it. `wait_for_socket` inside the tool
|
/// `Method` line, then returns it. `wait_for_socket` inside the tool
|
||||||
/// makes a probe connection that carries no data, so the task must
|
/// makes a probe connection that carries no data, so the task must
|
||||||
/// tolerate an empty connection and keep listening.
|
/// tolerate an empty connection and keep listening.
|
||||||
fn accept_one_method(
|
fn accept_one_method(listener: UnixListener) -> tokio::task::JoinHandle<Option<Method>> {
|
||||||
listener: UnixListener,
|
|
||||||
) -> tokio::task::JoinHandle<Option<Method>> {
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = listener.accept().await.ok()?;
|
let (stream, _) = listener.accept().await.ok()?;
|
||||||
|
|
@ -192,7 +190,11 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
let output: ToolOutput = tool.execute(&input).await.unwrap();
|
||||||
assert!(output.summary.contains("child"), "summary: {}", output.summary);
|
assert!(
|
||||||
|
output.summary.contains("child"),
|
||||||
|
"summary: {}",
|
||||||
|
output.summary
|
||||||
|
);
|
||||||
|
|
||||||
// Verify the tool delivered Method::Run to the socket.
|
// Verify the tool delivered Method::Run to the socket.
|
||||||
let method = received.await.unwrap().expect("expected one Method line");
|
let method = received.await.unwrap().expect("expected one Method line");
|
||||||
|
|
@ -261,7 +263,10 @@ async fn spawn_pod_rejects_scope_outside_spawner() {
|
||||||
let err = tool.execute(&input).await.unwrap_err();
|
let err = tool.execute(&input).await.unwrap_err();
|
||||||
match err {
|
match err {
|
||||||
ToolError::InvalidArgument(msg) => {
|
ToolError::InvalidArgument(msg) => {
|
||||||
assert!(msg.contains("not within"), "expected NotSubset wording: {msg}");
|
assert!(
|
||||||
|
msg.contains("not within"),
|
||||||
|
"expected NotSubset wording: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
other => panic!("expected InvalidArgument, got {other:?}"),
|
other => panic!("expected InvalidArgument, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,11 +246,11 @@ async fn agents_md_absent_omits_trailing_section() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn agents_md_not_reread_after_compact() {
|
async fn agents_md_not_reread_after_compact() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
single_text_events("a"), // pod.run_text("first")
|
single_text_events("a"), // pod.run_text("first")
|
||||||
single_text_events("b"), // pod.run_text("second")
|
single_text_events("b"), // pod.run_text("second")
|
||||||
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
||||||
single_text_events("done"), // compact worker: close
|
single_text_events("done"), // compact worker: close
|
||||||
single_text_events("c"), // pod.run_text("third")
|
single_text_events("c"), // pod.run_text("third")
|
||||||
]);
|
]);
|
||||||
let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
|
let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
|
||||||
let agents_path = pwd.join("AGENTS.md");
|
let agents_path = pwd.join("AGENTS.md");
|
||||||
|
|
@ -278,11 +278,11 @@ async fn agents_md_not_reread_after_compact() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compact_preserves_system_prompt() {
|
async fn compact_preserves_system_prompt() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
single_text_events("a"), // pod.run_text("first")
|
single_text_events("a"), // pod.run_text("first")
|
||||||
single_text_events("b"), // pod.run_text("second")
|
single_text_events("b"), // pod.run_text("second")
|
||||||
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
||||||
single_text_events("done"), // compact worker: close
|
single_text_events("done"), // compact worker: close
|
||||||
single_text_events("c"), // pod.run_text("third")
|
single_text_events("c"), // pod.run_text("third")
|
||||||
]);
|
]);
|
||||||
let (mut pod, _pwd) = make_pod_with_body("SP cwd={{ cwd }}", client)
|
let (mut pod, _pwd) = make_pod_with_body("SP cwd={{ cwd }}", client)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||||
pub enum Method {
|
pub enum Method {
|
||||||
Run { input: Vec<Segment> },
|
Run {
|
||||||
|
input: Vec<Segment>,
|
||||||
|
},
|
||||||
/// Human-readable text injected into the target Pod's LLM context
|
/// Human-readable text injected into the target Pod's LLM context
|
||||||
/// as a non-blocking system message. No side effects beyond LLM
|
/// as a non-blocking system message. No side effects beyond LLM
|
||||||
/// context; use `PodEvent` for typed lifecycle reports.
|
/// context; use `PodEvent` for typed lifecycle reports.
|
||||||
Notify { message: String },
|
Notify {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
/// Typed lifecycle report from a child Pod to its direct parent.
|
/// Typed lifecycle report from a child Pod to its direct parent.
|
||||||
PodEvent(PodEvent),
|
PodEvent(PodEvent),
|
||||||
Resume,
|
Resume,
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,7 @@ pub enum ResolveError {
|
||||||
MalformedRef(String),
|
MalformedRef(String),
|
||||||
#[error("model.ref points to unknown provider `{0}`")]
|
#[error("model.ref points to unknown provider `{0}`")]
|
||||||
UnknownProvider(String),
|
UnknownProvider(String),
|
||||||
#[error(
|
#[error("model.ref omitted; manifest must specify scheme, model_id, and auth (missing: {0})")]
|
||||||
"model.ref omitted; manifest must specify scheme, model_id, and auth (missing: {0})"
|
|
||||||
)]
|
|
||||||
InlineMissing(&'static str),
|
InlineMissing(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,8 +257,8 @@ pub fn resolve_with_catalogs(
|
||||||
models: &[ModelEntry],
|
models: &[ModelEntry],
|
||||||
) -> Result<ModelConfig, ResolveError> {
|
) -> Result<ModelConfig, ResolveError> {
|
||||||
if let Some(ref_str) = &manifest.ref_ {
|
if let Some(ref_str) = &manifest.ref_ {
|
||||||
let (provider_id, ref_model_id) = split_ref(ref_str)
|
let (provider_id, ref_model_id) =
|
||||||
.ok_or_else(|| ResolveError::MalformedRef(ref_str.clone()))?;
|
split_ref(ref_str).ok_or_else(|| ResolveError::MalformedRef(ref_str.clone()))?;
|
||||||
let provider = providers
|
let provider = providers
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.id == provider_id)
|
.find(|p| p.id == provider_id)
|
||||||
|
|
@ -371,10 +369,7 @@ mod tests {
|
||||||
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||||
assert_eq!(cfg.scheme, SchemeKind::Anthropic);
|
assert_eq!(cfg.scheme, SchemeKind::Anthropic);
|
||||||
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
|
assert_eq!(cfg.model_id, "claude-sonnet-4-6");
|
||||||
assert_eq!(
|
assert_eq!(cfg.base_url.as_deref(), Some("https://api.anthropic.com"));
|
||||||
cfg.base_url.as_deref(),
|
|
||||||
Some("https://api.anthropic.com")
|
|
||||||
);
|
|
||||||
match cfg.auth {
|
match cfg.auth {
|
||||||
AuthRef::ApiKey { env, file } => {
|
AuthRef::ApiKey { env, file } => {
|
||||||
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
|
assert_eq!(env.as_deref(), Some("INSOMNIA_API_KEY_ANTHROPIC"));
|
||||||
|
|
@ -382,7 +377,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
_ => panic!("expected ApiKey auth from provider hint"),
|
_ => panic!("expected ApiKey auth from provider hint"),
|
||||||
}
|
}
|
||||||
assert!(cfg.capability.is_some(), "should fall back to provider.default_capability");
|
assert!(
|
||||||
|
cfg.capability.is_some(),
|
||||||
|
"should fall back to provider.default_capability"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ impl AuthSnapshot {
|
||||||
let refresh_token = tokens
|
let refresh_token = tokens
|
||||||
.get("refresh_token")
|
.get("refresh_token")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into()))?
|
.ok_or_else(|| {
|
||||||
|
CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into())
|
||||||
|
})?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let id_token = tokens
|
let id_token = tokens
|
||||||
|
|
@ -58,9 +60,7 @@ impl AuthSnapshot {
|
||||||
.get("account_id")
|
.get("account_id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.or_else(|| {
|
.or_else(|| super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id))
|
||||||
super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id)
|
|
||||||
})
|
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
CodexAuthError::MalformedAuthJson(
|
CodexAuthError::MalformedAuthJson(
|
||||||
"missing account_id in both tokens and id_token claims".into(),
|
"missing account_id in both tokens and id_token claims".into(),
|
||||||
|
|
@ -131,7 +131,10 @@ pub async fn persist_refreshed(
|
||||||
}
|
}
|
||||||
raw.as_object_mut()
|
raw.as_object_mut()
|
||||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
|
.ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))?
|
||||||
.insert("last_refresh".into(), Value::String(Utc::now().to_rfc3339()));
|
.insert(
|
||||||
|
"last_refresh".into(),
|
||||||
|
Value::String(Utc::now().to_rfc3339()),
|
||||||
|
);
|
||||||
|
|
||||||
write_atomic(path, raw)?;
|
write_atomic(path, raw)?;
|
||||||
AuthSnapshot::from_value(raw.clone())
|
AuthSnapshot::from_value(raw.clone())
|
||||||
|
|
@ -139,9 +142,8 @@ pub async fn persist_refreshed(
|
||||||
|
|
||||||
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).map_err(|e| {
|
std::fs::create_dir_all(parent)
|
||||||
CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display()))
|
.map_err(|e| CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display())))?;
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
let json = serde_json::to_vec_pretty(value)
|
let json = serde_json::to_vec_pretty(value)
|
||||||
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
|
.map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?;
|
||||||
|
|
@ -203,7 +205,10 @@ mod tests {
|
||||||
assert_eq!(snap.account_id, "acc-1");
|
assert_eq!(snap.account_id, "acc-1");
|
||||||
assert!(snap.last_refresh.is_some());
|
assert!(snap.last_refresh.is_some());
|
||||||
// 未知フィールドが raw に保持されている
|
// 未知フィールドが raw に保持されている
|
||||||
assert_eq!(snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str), Some("sk-extra"));
|
assert_eq!(
|
||||||
|
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||||
|
Some("sk-extra")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
@ -238,7 +243,10 @@ mod tests {
|
||||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||||
}"#,
|
}"#,
|
||||||
);
|
);
|
||||||
let updated = persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into())).await.unwrap();
|
let updated =
|
||||||
|
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(updated.access_token, "new-acc");
|
assert_eq!(updated.access_token, "new-acc");
|
||||||
assert_eq!(updated.refresh_token, "new-ref");
|
assert_eq!(updated.refresh_token, "new-ref");
|
||||||
// 未知フィールド agent_identity が保たれる
|
// 未知フィールド agent_identity が保たれる
|
||||||
|
|
@ -258,7 +266,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
persist_refreshed(&path, None, Some("a2".into()), None).await.unwrap();
|
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||||
assert_eq!(mode, 0o600);
|
assert_eq!(mode, 0o600);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use llm_worker::llm_client::{
|
use llm_worker::llm_client::{ClientError, auth::AuthProvider};
|
||||||
ClientError,
|
|
||||||
auth::AuthProvider,
|
|
||||||
};
|
|
||||||
use reqwest::header::{HeaderName, HeaderValue};
|
use reqwest::header::{HeaderName, HeaderValue};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
|
@ -66,8 +63,8 @@ impl CodexAuthProvider {
|
||||||
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
|
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
|
||||||
PathBuf::from(p)
|
PathBuf::from(p)
|
||||||
} else {
|
} else {
|
||||||
let home = std::env::var("HOME")
|
let home =
|
||||||
.map_err(|_| ClientError::Config("HOME not set".into()))?;
|
std::env::var("HOME").map_err(|_| ClientError::Config("HOME not set".into()))?;
|
||||||
PathBuf::from(home).join(".codex")
|
PathBuf::from(home).join(".codex")
|
||||||
};
|
};
|
||||||
Ok(Self::new(codex_home))
|
Ok(Self::new(codex_home))
|
||||||
|
|
@ -142,7 +139,9 @@ impl CodexAuthProvider {
|
||||||
Ok(new_snap)
|
Ok(new_snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_headers(snap: &AuthSnapshot) -> Result<Vec<(HeaderName, HeaderValue)>, CodexAuthError> {
|
fn build_headers(
|
||||||
|
snap: &AuthSnapshot,
|
||||||
|
) -> Result<Vec<(HeaderName, HeaderValue)>, CodexAuthError> {
|
||||||
let mut out = Vec::with_capacity(5);
|
let mut out = Vec::with_capacity(5);
|
||||||
|
|
||||||
let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token))
|
let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token))
|
||||||
|
|
@ -151,10 +150,7 @@ impl CodexAuthProvider {
|
||||||
|
|
||||||
let acc_val = HeaderValue::from_str(&snap.account_id)
|
let acc_val = HeaderValue::from_str(&snap.account_id)
|
||||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
|
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
|
||||||
out.push((
|
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
|
||||||
HeaderName::from_static("chatgpt-account-id"),
|
|
||||||
acc_val,
|
|
||||||
));
|
|
||||||
|
|
||||||
// Cloudflare WAF は ChatGPT backend アクセス元を `originator` /
|
// Cloudflare WAF は ChatGPT backend アクセス元を `originator` /
|
||||||
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
|
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
|
||||||
|
|
@ -186,7 +182,10 @@ impl CodexAuthProvider {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AuthProvider for CodexAuthProvider {
|
impl AuthProvider for CodexAuthProvider {
|
||||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||||
let snap = self.ensure_fresh().await.map_err(CodexAuthError::to_client_error)?;
|
let snap = self
|
||||||
|
.ensure_fresh()
|
||||||
|
.await
|
||||||
|
.map_err(CodexAuthError::to_client_error)?;
|
||||||
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
|
Self::build_headers(&snap).map_err(CodexAuthError::to_client_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -247,7 +246,10 @@ mod tests {
|
||||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||||
|
|
||||||
let headers = provider.headers().await.unwrap();
|
let headers = provider.headers().await.unwrap();
|
||||||
let names: Vec<_> = headers.iter().map(|(n, _)| n.as_str().to_string()).collect();
|
let names: Vec<_> = headers
|
||||||
|
.iter()
|
||||||
|
.map(|(n, _)| n.as_str().to_string())
|
||||||
|
.collect();
|
||||||
assert!(names.contains(&"authorization".to_string()));
|
assert!(names.contains(&"authorization".to_string()));
|
||||||
assert!(names.contains(&"chatgpt-account-id".to_string()));
|
assert!(names.contains(&"chatgpt-account-id".to_string()));
|
||||||
assert!(!names.contains(&"x-openai-fedramp".to_string()));
|
assert!(!names.contains(&"x-openai-fedramp".to_string()));
|
||||||
|
|
@ -276,7 +278,12 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn refreshes_when_expired_and_persists() {
|
async fn refreshes_when_expired_and_persists() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = write_auth(dir.path(), Utc::now().timestamp() - 60, false, "old-refresh");
|
let path = write_auth(
|
||||||
|
dir.path(),
|
||||||
|
Utc::now().timestamp() - 60,
|
||||||
|
false,
|
||||||
|
"old-refresh",
|
||||||
|
);
|
||||||
|
|
||||||
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
|
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
|
|
@ -315,7 +322,12 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn permanent_refresh_failure_surfaces_login_message() {
|
async fn permanent_refresh_failure_surfaces_login_message() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_auth(dir.path(), Utc::now().timestamp() - 60, false, "bad-refresh");
|
write_auth(
|
||||||
|
dir.path(),
|
||||||
|
Utc::now().timestamp() - 60,
|
||||||
|
false,
|
||||||
|
"bad-refresh",
|
||||||
|
);
|
||||||
|
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,10 @@ fn extract_error_code(body: &str) -> Option<String> {
|
||||||
return Some(s.to_string());
|
return Some(s.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value.get("code").and_then(|v| v.as_str()).map(str::to_string)
|
value
|
||||||
|
.get("code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,7 @@ pub enum ProviderError {
|
||||||
/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照
|
/// 1. `AuthRef::ApiKey { env, .. }` で env が指定されていればその変数を参照
|
||||||
/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`)
|
/// 2. そうでなければ scheme 既定の環境変数 (`SchemeKind::default_env_var`)
|
||||||
/// 3. それでも無ければ `file` を読む(絶対パスのみ)
|
/// 3. それでも無ければ `file` を読む(絶対パスのみ)
|
||||||
fn resolve_auth(
|
fn resolve_auth(scheme: SchemeKind, auth: &AuthRef) -> Result<ResolvedAuth, ProviderError> {
|
||||||
scheme: SchemeKind,
|
|
||||||
auth: &AuthRef,
|
|
||||||
) -> Result<ResolvedAuth, ProviderError> {
|
|
||||||
match auth {
|
match auth {
|
||||||
AuthRef::None => Ok(ResolvedAuth::None),
|
AuthRef::None => Ok(ResolvedAuth::None),
|
||||||
AuthRef::ApiKey { env, file } => {
|
AuthRef::ApiKey { env, file } => {
|
||||||
|
|
@ -161,9 +158,7 @@ pub fn build_client(manifest: &ModelManifest) -> Result<Box<dyn LlmClient>, Prov
|
||||||
/// `ModelManifest` から既に `catalog::resolve_model_manifest` を通した
|
/// `ModelManifest` から既に `catalog::resolve_model_manifest` を通した
|
||||||
/// ケース(factory / spawn 経路でカタログ引きを 1 回だけにしたい等)で
|
/// ケース(factory / spawn 経路でカタログ引きを 1 回だけにしたい等)で
|
||||||
/// 使う。
|
/// 使う。
|
||||||
pub fn build_client_from_config(
|
pub fn build_client_from_config(config: &ModelConfig) -> Result<Box<dyn LlmClient>, ProviderError> {
|
||||||
config: &ModelConfig,
|
|
||||||
) -> Result<Box<dyn LlmClient>, ProviderError> {
|
|
||||||
build_from_config(config)
|
build_from_config(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,19 +136,14 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::ToolCallDone {
|
Event::ToolCallDone { id, arguments, .. } => {
|
||||||
id, arguments, ..
|
|
||||||
} => {
|
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
if let Some(b) = self.find_tool_call_mut(&id) {
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
||||||
b.arguments = Some(arguments);
|
b.arguments = Some(arguments);
|
||||||
// Only advance the state when it's still in-flight.
|
// Only advance the state when it's still in-flight.
|
||||||
// If a ToolResult arrived out of order and already
|
// If a ToolResult arrived out of order and already
|
||||||
// transitioned us to Done/Error, keep that.
|
// transitioned us to Done/Error, keep that.
|
||||||
if matches!(
|
if matches!(b.state, ToolCallState::Pending | ToolCallState::Streaming) {
|
||||||
b.state,
|
|
||||||
ToolCallState::Pending | ToolCallState::Streaming
|
|
||||||
) {
|
|
||||||
b.state = ToolCallState::Executing;
|
b.state = ToolCallState::Executing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +186,12 @@ impl App {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !is_error {
|
if !is_error {
|
||||||
apply_cache_update(&mut self.cache, &name, args.as_deref(), output.as_deref());
|
apply_cache_update(
|
||||||
|
&mut self.cache,
|
||||||
|
&name,
|
||||||
|
args.as_deref(),
|
||||||
|
output.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Result for an unknown tool call. Surface it as an
|
// Result for an unknown tool call. Surface it as an
|
||||||
|
|
@ -291,9 +291,7 @@ impl App {
|
||||||
if let Block::ToolCall(tc) = b {
|
if let Block::ToolCall(tc) = b {
|
||||||
if matches!(
|
if matches!(
|
||||||
tc.state,
|
tc.state,
|
||||||
ToolCallState::Pending
|
ToolCallState::Pending | ToolCallState::Streaming | ToolCallState::Executing
|
||||||
| ToolCallState::Streaming
|
|
||||||
| ToolCallState::Executing
|
|
||||||
) {
|
) {
|
||||||
tc.state = ToolCallState::Incomplete;
|
tc.state = ToolCallState::Incomplete;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -450,7 +448,10 @@ impl App {
|
||||||
// Incomplete so the replay matches live semantics.
|
// Incomplete so the replay matches live semantics.
|
||||||
for b in self.blocks.iter_mut() {
|
for b in self.blocks.iter_mut() {
|
||||||
if let Block::ToolCall(tc) = b
|
if let Block::ToolCall(tc) = b
|
||||||
&& matches!(tc.state, ToolCallState::Executing | ToolCallState::Pending | ToolCallState::Streaming)
|
&& matches!(
|
||||||
|
tc.state,
|
||||||
|
ToolCallState::Executing | ToolCallState::Pending | ToolCallState::Streaming
|
||||||
|
)
|
||||||
{
|
{
|
||||||
tc.state = ToolCallState::Incomplete;
|
tc.state = ToolCallState::Incomplete;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,15 @@ pub enum ToolCallState {
|
||||||
/// `ToolCallDone` received, waiting on the tool result.
|
/// `ToolCallDone` received, waiting on the tool result.
|
||||||
Executing,
|
Executing,
|
||||||
/// `ToolResult { is_error: false, .. }` received.
|
/// `ToolResult { is_error: false, .. }` received.
|
||||||
Done { summary: String, output: Option<String> },
|
Done {
|
||||||
|
summary: String,
|
||||||
|
output: Option<String>,
|
||||||
|
},
|
||||||
/// `ToolResult { is_error: true, .. }` received.
|
/// `ToolResult { is_error: true, .. }` received.
|
||||||
Error { summary: String, output: Option<String> },
|
Error {
|
||||||
|
summary: String,
|
||||||
|
output: Option<String>,
|
||||||
|
},
|
||||||
/// Turn ended before a matching `ToolResult` arrived.
|
/// Turn ended before a matching `ToolResult` arrived.
|
||||||
Incomplete,
|
Incomplete,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,12 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||||
if let Some(p) = override_path {
|
if let Some(p) = override_path {
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
manifest::paths::pod_socket_path(pod_name)
|
manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp").join("insomnia").join(pod_name).join("sock"))
|
PathBuf::from("/tmp")
|
||||||
|
.join("insomnia")
|
||||||
|
.join(pod_name)
|
||||||
|
.join("sock")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Mode {
|
enum Mode {
|
||||||
|
|
@ -172,7 +176,10 @@ async fn run(
|
||||||
run_loop(terminal, &mut app, client, shutdown_pod_on_exit).await?;
|
run_loop(terminal, &mut app, client, shutdown_pod_on_exit).await?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.push_error(format!("Failed to connect to {}: {e}", socket_path.display()));
|
app.push_error(format!(
|
||||||
|
"Failed to connect to {}: {e}",
|
||||||
|
socket_path.display()
|
||||||
|
));
|
||||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
run_disconnected(&mut app)?;
|
run_disconnected(&mut app)?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,8 @@ use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::event::{
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers,
|
use manifest::{PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path};
|
||||||
};
|
|
||||||
use manifest::{
|
|
||||||
PodManifestConfig, find_project_manifest_from, load_layer, user_manifest_path,
|
|
||||||
};
|
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Layout};
|
use ratatui::layout::{Constraint, Layout};
|
||||||
|
|
@ -103,7 +99,10 @@ pub async fn run() -> Result<SpawnOutcome, SpawnError> {
|
||||||
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
||||||
|
|
||||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||||
for layer in [user_layer.as_ref(), project_layer.as_ref()].into_iter().flatten() {
|
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
cascade = cascade.merge(layer.clone());
|
cascade = cascade.merge(layer.clone());
|
||||||
}
|
}
|
||||||
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
||||||
|
|
@ -147,8 +146,7 @@ pub async fn run() -> Result<SpawnOutcome, SpawnError> {
|
||||||
None => continue,
|
None => continue,
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
if form.name.trim().is_empty() {
|
if form.name.trim().is_empty() {
|
||||||
form.message =
|
form.message = Some(("name is required".to_string(), MessageKind::Error));
|
||||||
Some(("name is required".to_string(), MessageKind::Error));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -358,7 +356,10 @@ fn build_overlay_toml(form: &Form) -> String {
|
||||||
);
|
);
|
||||||
rule.insert("permission".into(), toml::Value::String("write".into()));
|
rule.insert("permission".into(), toml::Value::String("write".into()));
|
||||||
let mut scope = toml::value::Table::new();
|
let mut scope = toml::value::Table::new();
|
||||||
scope.insert("allow".into(), toml::Value::Array(vec![toml::Value::Table(rule)]));
|
scope.insert(
|
||||||
|
"allow".into(),
|
||||||
|
toml::Value::Array(vec![toml::Value::Table(rule)]),
|
||||||
|
);
|
||||||
root.insert("scope".into(), toml::Value::Table(scope));
|
root.insert("scope".into(), toml::Value::Table(scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,7 +383,6 @@ fn resolve_pod_command() -> PathBuf {
|
||||||
PathBuf::from("pod")
|
PathBuf::from("pod")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct StderrTail {
|
struct StderrTail {
|
||||||
lines: std::collections::VecDeque<String>,
|
lines: std::collections::VecDeque<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -529,7 +529,9 @@ fn name_line(form: &Form) -> Line<'_> {
|
||||||
Span::styled("name: ", Style::default().fg(Color::DarkGray)),
|
Span::styled("name: ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
form.name.as_str(),
|
form.name.as_str(),
|
||||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,12 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let in_progress = group
|
let in_progress = group.iter().any(|tc| {
|
||||||
.iter()
|
!matches!(
|
||||||
.any(|tc| !matches!(tc.state, ToolCallState::Done { .. } | ToolCallState::Error { .. } | ToolCallState::Incomplete));
|
tc.state,
|
||||||
|
ToolCallState::Done { .. } | ToolCallState::Error { .. } | ToolCallState::Incomplete
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
let paths: Vec<String> = group.iter().map(|tc| read_path(tc)).collect();
|
let paths: Vec<String> = group.iter().map(|tc| read_path(tc)).collect();
|
||||||
let count = paths.len();
|
let count = paths.len();
|
||||||
|
|
@ -89,9 +92,7 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend
|
||||||
} else {
|
} else {
|
||||||
format!("Read — {count} file{} read", plural(count))
|
format!("Read — {count} file{} read", plural(count))
|
||||||
};
|
};
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![Span::styled(header, tool_style)]));
|
||||||
Span::styled(header, tool_style),
|
|
||||||
]));
|
|
||||||
|
|
||||||
if matches!(mode, Mode::Overview) {
|
if matches!(mode, Mode::Overview) {
|
||||||
return ToolRenderOutput {
|
return ToolRenderOutput {
|
||||||
|
|
@ -169,17 +170,15 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
|
||||||
])];
|
])];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lines = vec![
|
let mut lines = vec![Line::from(vec![
|
||||||
Line::from(vec![
|
Span::styled("Write — ".to_owned(), tool_style),
|
||||||
Span::styled("Write — ".to_owned(), tool_style),
|
Span::styled(format!("{label} "), label_style),
|
||||||
Span::styled(format!("{label} "), label_style),
|
Span::styled(path.clone(), Style::default().fg(Color::White)),
|
||||||
Span::styled(path.clone(), Style::default().fg(Color::White)),
|
Span::styled(
|
||||||
Span::styled(
|
format!(" ({})", state_suffix(&tc.state)),
|
||||||
format!(" ({})", state_suffix(&tc.state)),
|
Style::default().fg(Color::DarkGray),
|
||||||
Style::default().fg(Color::DarkGray),
|
),
|
||||||
),
|
])];
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Body preview.
|
// Body preview.
|
||||||
let cap = match mode {
|
let cap = match mode {
|
||||||
|
|
@ -214,7 +213,12 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'
|
||||||
// Edit
|
// Edit
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn render_edit(cache: &FileCache, tc: &ToolCallBlock, width: u16, mode: Mode) -> Vec<Line<'static>> {
|
fn render_edit(
|
||||||
|
cache: &FileCache,
|
||||||
|
tc: &ToolCallBlock,
|
||||||
|
width: u16,
|
||||||
|
mode: Mode,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
let args = parsed_args(tc);
|
let args = parsed_args(tc);
|
||||||
let path = args
|
let path = args
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -296,9 +300,7 @@ fn build_edit_diff(content: &str, old: &str, new: &str, width: u16) -> Vec<Line<
|
||||||
|
|
||||||
// Width for the line-number gutter: fit the largest number we'll
|
// Width for the line-number gutter: fit the largest number we'll
|
||||||
// print across either file's version of this hunk.
|
// print across either file's version of this hunk.
|
||||||
let max_line = ctx_end
|
let max_line = ctx_end.max(line_of_idx + new_line_count).max(1);
|
||||||
.max(line_of_idx + new_line_count)
|
|
||||||
.max(1);
|
|
||||||
let num_w = max_line.to_string().len();
|
let num_w = max_line.to_string().len();
|
||||||
|
|
||||||
// BG-highlighted rows for -/+ so the change stripe extends full
|
// BG-highlighted rows for -/+ so the change stripe extends full
|
||||||
|
|
@ -512,12 +514,10 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec<Line<'stati
|
||||||
])];
|
])];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lines = vec![Line::from(vec![
|
let mut lines = vec![Line::from(vec![Span::styled(
|
||||||
Span::styled(
|
format!("{label} — {}", state_suffix(&tc.state)),
|
||||||
format!("{label} — {}", state_suffix(&tc.state)),
|
tool_style,
|
||||||
tool_style,
|
)])];
|
||||||
),
|
|
||||||
])];
|
|
||||||
|
|
||||||
let cap = match mode {
|
let cap = match mode {
|
||||||
Mode::Normal => NORMAL_MAX_BODY,
|
Mode::Normal => NORMAL_MAX_BODY,
|
||||||
|
|
@ -565,17 +565,13 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
|
||||||
} else {
|
} else {
|
||||||
format!("{} — {suffix}", tc.name)
|
format!("{} — {suffix}", tc.name)
|
||||||
};
|
};
|
||||||
return vec![Line::from(vec![
|
return vec![Line::from(vec![Span::styled(label, tool_style)])];
|
||||||
Span::styled(label, tool_style),
|
|
||||||
])];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lines = vec![Line::from(vec![
|
let mut lines = vec![Line::from(vec![Span::styled(
|
||||||
Span::styled(
|
format!("{} — {}", tc.name, state_suffix(&tc.state)),
|
||||||
format!("{} — {}", tc.name, state_suffix(&tc.state)),
|
tool_style,
|
||||||
tool_style,
|
)])];
|
||||||
),
|
|
||||||
])];
|
|
||||||
|
|
||||||
let args_pretty = parsed_args(tc)
|
let args_pretty = parsed_args(tc)
|
||||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||||
|
|
@ -589,7 +585,9 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec<Line<'static>> {
|
||||||
&mut lines,
|
&mut lines,
|
||||||
&args_pretty,
|
&args_pretty,
|
||||||
arg_cap,
|
arg_cap,
|
||||||
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
|
Style::default()
|
||||||
|
.fg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
);
|
);
|
||||||
|
|
||||||
let summary_source: String = match &tc.state {
|
let summary_source: String = match &tc.state {
|
||||||
|
|
@ -660,12 +658,7 @@ fn maybe_error_line(lines: &mut Vec<Line<'static>>, state: &ToolCallState) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_capped_lines(
|
fn emit_capped_lines(out: &mut Vec<Line<'static>>, text: &str, cap: usize, style: Style) {
|
||||||
out: &mut Vec<Line<'static>>,
|
|
||||||
text: &str,
|
|
||||||
cap: usize,
|
|
||||||
style: Style,
|
|
||||||
) {
|
|
||||||
let all: Vec<&str> = text.lines().collect();
|
let all: Vec<&str> = text.lines().collect();
|
||||||
let shown = all.len().min(cap);
|
let shown = all.len().min(cap);
|
||||||
for l in &all[..shown] {
|
for l in &all[..shown] {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use protocol::{AlertLevel, Greeting, Segment};
|
use protocol::{AlertLevel, Greeting, Segment};
|
||||||
|
|
||||||
use crate::app::{App, fmt_tokens, alert_source_label};
|
use crate::app::{App, alert_source_label, fmt_tokens};
|
||||||
use crate::block::{Block, CompactEvent};
|
use crate::block::{Block, CompactEvent};
|
||||||
|
|
||||||
/// Display density for the history view.
|
/// Display density for the history view.
|
||||||
|
|
@ -64,10 +64,10 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||||
let input_height = input_area_height(&input_render, area.height);
|
let input_height = input_area_height(&input_render, area.height);
|
||||||
|
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Min(0), // history view
|
Constraint::Min(0), // history view
|
||||||
Constraint::Length(1), // separator
|
Constraint::Length(1), // separator
|
||||||
Constraint::Length(1), // status
|
Constraint::Length(1), // status
|
||||||
Constraint::Length(input_height), // input area
|
Constraint::Length(input_height), // input area
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
|
|
@ -219,21 +219,20 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>)
|
||||||
*pending_width = 0;
|
*pending_width = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
let push_row = |current: &mut Vec<Span<'static>>,
|
let push_row =
|
||||||
row_width: &mut usize,
|
|current: &mut Vec<Span<'static>>, row_width: &mut usize, out: &mut Vec<Line<'static>>| {
|
||||||
out: &mut Vec<Line<'static>>| {
|
if fill_to_width && *row_width < w {
|
||||||
if fill_to_width && *row_width < w {
|
let pad = w - *row_width;
|
||||||
let pad = w - *row_width;
|
current.push(Span::styled(" ".repeat(pad), line_style));
|
||||||
current.push(Span::styled(" ".repeat(pad), line_style));
|
*row_width = w;
|
||||||
*row_width = w;
|
}
|
||||||
}
|
let mut l = Line::from(std::mem::take(current)).style(line_style);
|
||||||
let mut l = Line::from(std::mem::take(current)).style(line_style);
|
if let Some(a) = alignment {
|
||||||
if let Some(a) = alignment {
|
l = l.alignment(a);
|
||||||
l = l.alignment(a);
|
}
|
||||||
}
|
out.push(l);
|
||||||
out.push(l);
|
*row_width = 0;
|
||||||
*row_width = 0;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
for span in line.spans {
|
for span in line.spans {
|
||||||
if !pending.is_empty() && span.style != pending_style {
|
if !pending.is_empty() && span.style != pending_style {
|
||||||
|
|
@ -276,12 +275,7 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec<Line<'static>>)
|
||||||
push_row(&mut current, &mut row_width, out);
|
push_row(&mut current, &mut row_width, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_block_into(
|
fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16, mode: Mode) {
|
||||||
lines: &mut Vec<Line<'static>>,
|
|
||||||
block: &Block,
|
|
||||||
width: u16,
|
|
||||||
mode: Mode,
|
|
||||||
) {
|
|
||||||
match block {
|
match block {
|
||||||
Block::Greeting(g) => match mode {
|
Block::Greeting(g) => match mode {
|
||||||
Mode::Overview => {
|
Mode::Overview => {
|
||||||
|
|
@ -426,10 +420,7 @@ fn segment_display_text(seg: &Segment) -> String {
|
||||||
match seg {
|
match seg {
|
||||||
Segment::Text { content } => content.replace('\n', " "),
|
Segment::Text { content } => content.replace('\n', " "),
|
||||||
Segment::Paste {
|
Segment::Paste {
|
||||||
id,
|
id, chars, lines, ..
|
||||||
chars,
|
|
||||||
lines,
|
|
||||||
..
|
|
||||||
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
|
||||||
Segment::FileRef { path } => format!("@{path}"),
|
Segment::FileRef { path } => format!("@{path}"),
|
||||||
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
Segment::KnowledgeRef { slug } => format!("#{slug}"),
|
||||||
|
|
@ -554,16 +545,19 @@ fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16
|
||||||
let (text, kind) = match evt {
|
let (text, kind) = match evt {
|
||||||
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
|
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
|
||||||
CompactEvent::Done { new_session_id } => {
|
CompactEvent::Done { new_session_id } => {
|
||||||
let short = new_session_id.to_string().chars().take(8).collect::<String>();
|
let short = new_session_id
|
||||||
|
.to_string()
|
||||||
|
.chars()
|
||||||
|
.take(8)
|
||||||
|
.collect::<String>();
|
||||||
(
|
(
|
||||||
format!("[compact] done (new session {short})"),
|
format!("[compact] done (new session {short})"),
|
||||||
MessageKind::NoticeWarn,
|
MessageKind::NoticeWarn,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CompactEvent::Failed { error } => (
|
CompactEvent::Failed { error } => {
|
||||||
format!("[compact error] {error}"),
|
(format!("[compact error] {error}"), MessageKind::NoticeError)
|
||||||
MessageKind::NoticeError,
|
}
|
||||||
),
|
|
||||||
};
|
};
|
||||||
match mode {
|
match mode {
|
||||||
Mode::Overview => push_overview_line(lines, &text, width, kind, ""),
|
Mode::Overview => push_overview_line(lines, &text, width, kind, ""),
|
||||||
|
|
@ -772,4 +766,3 @@ pub fn kind_style(kind: MessageKind) -> Style {
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user