Compare commits
32 Commits
ef09a75fba
...
c113e55ee2
| Author | SHA1 | Date | |
|---|---|---|---|
| c113e55ee2 | |||
| 40a7185255 | |||
| be0658380e | |||
| 9a423d8a7a | |||
| 56a5e47d63 | |||
| fed461036a | |||
| 68011f6628 | |||
| 82dcc57475 | |||
| d9d36cabf4 | |||
| d98f2ff7df | |||
| 63dc466e63 | |||
| 95e08956eb | |||
| d42b4c22e1 | |||
| dc986dc8e5 | |||
| e4a4bf62f1 | |||
| b391cb88e4 | |||
| 2be3a5bd36 | |||
| 4c3ba12b43 | |||
| 2b37ccb9a5 | |||
| a3d8a5465e | |||
| 7a6d9c33f3 | |||
| 37ead9c1a8 | |||
| 51e5354afe | |||
| b9891e6127 | |||
| 20ac7405e4 | |||
| 504a928b82 | |||
| 27b1891f1c | |||
| 8ed5939ebb | |||
| b870a77a55 | |||
| 8f3c935f52 | |||
| 4c2b796345 | |||
| b14a141341 |
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3503,6 +3503,7 @@ dependencies = [
|
||||||
"ignore",
|
"ignore",
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
"reqwest",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,48 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_reasoning_items_are_preserved_across_user_turns() {
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
|
||||||
|
let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
|
||||||
|
let req = Request::new()
|
||||||
|
.user("old prompt")
|
||||||
|
.item(old_reasoning)
|
||||||
|
.assistant("old answer")
|
||||||
|
.user("new prompt")
|
||||||
|
.item(current_reasoning);
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
let encrypted: Vec<_> = body
|
||||||
|
.input
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match item {
|
||||||
|
InputItem::Reasoning {
|
||||||
|
encrypted_content, ..
|
||||||
|
} => encrypted_content.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(encrypted, vec!["OLD_ENC", "CURRENT_ENC"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_is_kept_across_function_call_loop() {
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let req = Request::new()
|
||||||
|
.user("run tool")
|
||||||
|
.item(Item::reasoning("plan").with_encrypted_content("ENC"))
|
||||||
|
.item(Item::tool_call("c1", "tool", "{}"))
|
||||||
|
.item(Item::tool_result("c1", "ok"));
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
|
||||||
|
assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
|
||||||
|
assert!(matches!(
|
||||||
|
body.input[3],
|
||||||
|
InputItem::FunctionCallOutput { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reasoning_summary_field_is_always_serialized() {
|
fn reasoning_summary_field_is_always_serialized() {
|
||||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||||
|
|
@ -509,6 +551,11 @@ mod tests {
|
||||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||||
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
||||||
assert_eq!(reasoning.summary, "auto");
|
assert_eq!(reasoning.summary, "auto");
|
||||||
|
let json = serde_json::to_value(reasoning).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.get("context").is_none(),
|
||||||
|
"reasoning.context must not be serialized, got: {json}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt};
|
||||||
use reqwest::header::{
|
use reqwest::header::{
|
||||||
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
use super::auth::{AuthProvider, AuthRequirement};
|
use super::auth::{AuthProvider, AuthRequirement};
|
||||||
use super::capability::ModelCapability;
|
use super::capability::ModelCapability;
|
||||||
|
|
@ -260,6 +260,55 @@ fn json_value_kind(value: &Value) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_body_shape_payload(body: &Value) -> Value {
|
||||||
|
let mut map = Map::new();
|
||||||
|
if let Some(input) = body.get("input").and_then(Value::as_array) {
|
||||||
|
let items_json_bytes = serde_json::to_vec(input).map(|bytes| bytes.len()).ok();
|
||||||
|
let mut reasoning_items = 0usize;
|
||||||
|
let mut reasoning_encrypted_content_count = 0usize;
|
||||||
|
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||||
|
for item in input {
|
||||||
|
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reasoning_items += 1;
|
||||||
|
if let Some(encrypted) = item.get("encrypted_content").and_then(Value::as_str) {
|
||||||
|
reasoning_encrypted_content_count += 1;
|
||||||
|
reasoning_encrypted_content_bytes += encrypted.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.insert("items_len".to_string(), json!(input.len()));
|
||||||
|
map.insert("items_json_bytes".to_string(), json!(items_json_bytes));
|
||||||
|
map.insert("reasoning_items".to_string(), json!(reasoning_items));
|
||||||
|
map.insert(
|
||||||
|
"reasoning_encrypted_content_count".to_string(),
|
||||||
|
json!(reasoning_encrypted_content_count),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
"reasoning_encrypted_content_bytes".to_string(),
|
||||||
|
json!(reasoning_encrypted_content_bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||||
|
match error {
|
||||||
|
ClientError::Api { code, .. } => code.as_deref(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||||
|
match error {
|
||||||
|
ClientError::Api { code, message, .. } => {
|
||||||
|
code.as_deref() == Some("context_length_exceeded")
|
||||||
|
|| message.contains("context_length_exceeded")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn response_with_timeout(
|
async fn response_with_timeout(
|
||||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
|
@ -296,7 +345,11 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
let error = json.get("error").unwrap_or(&json);
|
let error = json.get("error").unwrap_or(&json);
|
||||||
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
|
let code = error
|
||||||
|
.get("code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| error.get("type").and_then(|v| v.as_str()))
|
||||||
|
.map(String::from);
|
||||||
let message = error
|
let message = error
|
||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
|
@ -406,12 +459,14 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||||
let body = self
|
let body = self
|
||||||
.scheme
|
.scheme
|
||||||
.build_request_body(&self.model_id, &request, &self.capability);
|
.build_request_body(&self.model_id, &request, &self.capability);
|
||||||
|
let body_shape = request_body_shape_payload(&body);
|
||||||
emit_transport_trace(
|
emit_transport_trace(
|
||||||
&request,
|
&request,
|
||||||
"transport_body_build_done",
|
"transport_body_build_done",
|
||||||
json!({
|
json!({
|
||||||
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
||||||
"body_kind": json_value_kind(&body),
|
"body_kind": json_value_kind(&body),
|
||||||
|
"request_shape": body_shape.clone(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -438,6 +493,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||||
"encoding": request_body.encoding(),
|
"encoding": request_body.encoding(),
|
||||||
"raw_json_bytes": request_body.raw_json_bytes(),
|
"raw_json_bytes": request_body.raw_json_bytes(),
|
||||||
"wire_bytes": request_body.wire_bytes(),
|
"wire_bytes": request_body.wire_bytes(),
|
||||||
|
"request_shape": body_shape.clone(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -479,15 +535,23 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||||
};
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let retry_after_present = response.headers().get(RETRY_AFTER).is_some();
|
||||||
|
let error = classify_error_response(response).await;
|
||||||
|
let context_length_exceeded = is_context_length_exceeded(&error);
|
||||||
emit_transport_trace(
|
emit_transport_trace(
|
||||||
&request,
|
&request,
|
||||||
"transport_http_status_error",
|
"transport_http_status_error",
|
||||||
json!({
|
json!({
|
||||||
"status": response.status().as_u16(),
|
"status": status,
|
||||||
"retry_after_present": response.headers().get(RETRY_AFTER).is_some(),
|
"retry_after_present": retry_after_present,
|
||||||
|
"api_error_code": api_error_code(&error),
|
||||||
|
"context_length_exceeded": context_length_exceeded,
|
||||||
|
"provider_usage_absent": context_length_exceeded,
|
||||||
|
"request_shape": body_shape.clone(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return Err(classify_error_response(response).await);
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit_transport_trace(
|
emit_transport_trace(
|
||||||
|
|
@ -611,6 +675,23 @@ mod tests {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_body_shape_counts_reasoning_encrypted_content() {
|
||||||
|
let payload = request_body_shape_payload(&json!({
|
||||||
|
"reasoning": { "summary": "auto" },
|
||||||
|
"input": [
|
||||||
|
{ "type": "message", "role": "user", "content": [] },
|
||||||
|
{ "type": "reasoning", "encrypted_content": "abc", "summary": [] },
|
||||||
|
{ "type": "reasoning", "encrypted_content": "defgh", "summary": [] }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
assert_eq!(payload["items_len"], 3);
|
||||||
|
assert_eq!(payload["reasoning_items"], 2);
|
||||||
|
assert_eq!(payload["reasoning_encrypted_content_count"], 2);
|
||||||
|
assert_eq!(payload["reasoning_encrypted_content_bytes"], 8);
|
||||||
|
assert!(payload["items_json_bytes"].as_u64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
||||||
let err = response_with_timeout(
|
let err = response_with_timeout(
|
||||||
|
|
|
||||||
|
|
@ -2029,12 +2029,31 @@ fn items_trace_payload(
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut reasoning_items = 0usize;
|
||||||
|
let mut reasoning_encrypted_content_count = 0usize;
|
||||||
|
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||||
|
for item in items {
|
||||||
|
if let Item::Reasoning {
|
||||||
|
encrypted_content, ..
|
||||||
|
} = item
|
||||||
|
{
|
||||||
|
reasoning_items += 1;
|
||||||
|
if let Some(encrypted) = encrypted_content {
|
||||||
|
reasoning_encrypted_content_count += 1;
|
||||||
|
reasoning_encrypted_content_bytes += encrypted.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"items_len": items.len(),
|
"items_len": items.len(),
|
||||||
"items_json_bytes": serde_json::to_vec(items).map(|bytes| bytes.len()).ok(),
|
"items_json_bytes": serde_json::to_vec(items).map(|bytes| bytes.len()).ok(),
|
||||||
"tools_len": tools_len,
|
"tools_len": tools_len,
|
||||||
"cache_anchor": cache_anchor,
|
"cache_anchor": cache_anchor,
|
||||||
"cache_key_present": cache_key_present,
|
"cache_key_present": cache_key_present,
|
||||||
|
"reasoning_items": reasoning_items,
|
||||||
|
"reasoning_encrypted_content_count": reasoning_encrypted_content_count,
|
||||||
|
"reasoning_encrypted_content_bytes": reasoning_encrypted_content_bytes,
|
||||||
"last_item_kind": last.map(item_kind),
|
"last_item_kind": last.map(item_kind),
|
||||||
"last_item_json_bytes": last.and_then(|item| serde_json::to_vec(item).ok().map(|bytes| bytes.len())),
|
"last_item_json_bytes": last.and_then(|item| serde_json::to_vec(item).ok().map(|bytes| bytes.len())),
|
||||||
"last_tool_result": last_tool_result,
|
"last_tool_result": last_tool_result,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig,
|
CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig,
|
||||||
SessionConfig, SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
SessionConfig, SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
||||||
WorkerManifest,
|
WebConfig, WorkerManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Pod manifest. Every field is optional; one or more
|
/// Partial-form Pod manifest. Every field is optional; one or more
|
||||||
|
|
@ -46,6 +46,9 @@ pub struct PodManifestConfig {
|
||||||
pub permissions: Option<PermissionConfigPartial>,
|
pub permissions: Option<PermissionConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfigPartial>,
|
pub compaction: Option<CompactionConfigPartial>,
|
||||||
|
/// First-class web tool opt-in. See [`WebConfig`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub web: Option<WebConfig>,
|
||||||
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: Option<MemoryConfig>,
|
pub memory: Option<MemoryConfig>,
|
||||||
|
|
@ -296,6 +299,7 @@ impl PodManifestConfig {
|
||||||
upper.compaction,
|
upper.compaction,
|
||||||
CompactionConfigPartial::merge,
|
CompactionConfigPartial::merge,
|
||||||
),
|
),
|
||||||
|
web: merge_option(self.web, upper.web, WebConfig::merge),
|
||||||
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
|
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
|
||||||
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +313,50 @@ impl SkillsConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl WebConfig {
|
||||||
|
fn merge(self, upper: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: upper.enabled.or(self.enabled),
|
||||||
|
allow_private_addresses: upper
|
||||||
|
.allow_private_addresses
|
||||||
|
.or(self.allow_private_addresses),
|
||||||
|
search: merge_option(self.search, upper.search, crate::WebSearchConfig::merge),
|
||||||
|
fetch: merge_option(self.fetch, upper.fetch, crate::WebFetchConfig::merge),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::WebSearchConfig {
|
||||||
|
fn merge(self, upper: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: upper.enabled.or(self.enabled),
|
||||||
|
provider: upper.provider.or(self.provider),
|
||||||
|
api_key_env: upper.api_key_env.or(self.api_key_env),
|
||||||
|
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
|
||||||
|
base_url: upper.base_url.or(self.base_url),
|
||||||
|
country: upper.country.or(self.country),
|
||||||
|
search_lang: upper.search_lang.or(self.search_lang),
|
||||||
|
ui_lang: upper.ui_lang.or(self.ui_lang),
|
||||||
|
safesearch: upper.safesearch.or(self.safesearch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::WebFetchConfig {
|
||||||
|
fn merge(self, upper: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: upper.enabled.or(self.enabled),
|
||||||
|
timeout_secs: upper.timeout_secs.or(self.timeout_secs),
|
||||||
|
redirect_limit: upper.redirect_limit.or(self.redirect_limit),
|
||||||
|
max_response_bytes: upper.max_response_bytes.or(self.max_response_bytes),
|
||||||
|
max_output_bytes: upper.max_output_bytes.or(self.max_output_bytes),
|
||||||
|
allow_private_addresses: upper
|
||||||
|
.allow_private_addresses
|
||||||
|
.or(self.allow_private_addresses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl MemoryConfig {
|
impl MemoryConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -625,6 +673,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
|
||||||
session,
|
session,
|
||||||
permissions,
|
permissions,
|
||||||
compaction,
|
compaction,
|
||||||
|
web: cfg.web,
|
||||||
memory: cfg.memory,
|
memory: cfg.memory,
|
||||||
skills: cfg.skills,
|
skills: cfg.skills,
|
||||||
})
|
})
|
||||||
|
|
@ -671,6 +720,7 @@ mod tests {
|
||||||
permissions: None,
|
permissions: None,
|
||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
|
web: None,
|
||||||
memory: None,
|
memory: None,
|
||||||
skills: None,
|
skills: None,
|
||||||
}
|
}
|
||||||
|
|
@ -1036,6 +1086,14 @@ mod tests {
|
||||||
prune_protected_tokens: Some(5_000),
|
prune_protected_tokens: Some(5_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
web: Some(WebConfig {
|
||||||
|
search: Some(crate::WebSearchConfig {
|
||||||
|
api_key_env: Some("LOWER_BRAVE_KEY".into()),
|
||||||
|
timeout_secs: Some(12),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let upper = PodManifestConfig {
|
let upper = PodManifestConfig {
|
||||||
|
|
@ -1043,6 +1101,13 @@ mod tests {
|
||||||
threshold: Some(80_000),
|
threshold: Some(80_000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
|
web: Some(WebConfig {
|
||||||
|
search: Some(crate::WebSearchConfig {
|
||||||
|
timeout_secs: Some(3),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = lower.merge(upper);
|
let merged = lower.merge(upper);
|
||||||
|
|
@ -1050,6 +1115,9 @@ mod tests {
|
||||||
assert_eq!(c.threshold, Some(80_000));
|
assert_eq!(c.threshold, Some(80_000));
|
||||||
// field from lower retained when upper has None
|
// field from lower retained when upper has None
|
||||||
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
assert_eq!(c.prune_protected_tokens, Some(5_000));
|
||||||
|
let search = merged.web.unwrap().search.unwrap();
|
||||||
|
assert_eq!(search.timeout_secs, Some(3));
|
||||||
|
assert_eq!(search.api_key_env.as_deref(), Some("LOWER_BRAVE_KEY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ pub struct PodManifest {
|
||||||
/// memory tools registered.
|
/// memory tools registered.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: Option<MemoryConfig>,
|
pub memory: Option<MemoryConfig>,
|
||||||
|
/// First-class web tools configuration. Absent or `enabled = false` keeps
|
||||||
|
/// WebSearch/WebFetch registered but disabled, so no network access occurs
|
||||||
|
/// unless a manifest explicitly opts in.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web: Option<WebConfig>,
|
||||||
/// External Agent Skills (`SKILL.md`) directories to ingest as
|
/// External Agent Skills (`SKILL.md`) directories to ingest as
|
||||||
/// Workflows. Each entry is a path to a skills *root* (i.e. a
|
/// Workflows. Each entry is a path to a skills *root* (i.e. a
|
||||||
/// directory whose children are individual `<name>/SKILL.md` skill
|
/// directory whose children are individual `<name>/SKILL.md` skill
|
||||||
|
|
@ -79,6 +84,79 @@ pub struct SkillsConfig {
|
||||||
pub directories: Vec<PathBuf>,
|
pub directories: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for WebSearch and WebFetch built-in tools.
|
||||||
|
///
|
||||||
|
/// Network tools are fail-closed: absent config or `enabled = false` disables
|
||||||
|
/// both tools. Per-tool `enabled = false` can disable a tool under an enabled
|
||||||
|
/// global section.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WebConfig {
|
||||||
|
/// Global opt-in for web tools. Defaults to false when omitted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
/// Escape hatch for tests / trusted local deployments. Defaults to false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_private_addresses: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search: Option<WebSearchConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fetch: Option<WebFetchConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WebSearchProvider {
|
||||||
|
Brave,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSearch provider configuration.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WebSearchConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Option<WebSearchProvider>,
|
||||||
|
/// Environment variable that stores the provider API key. Raw secrets do
|
||||||
|
/// not belong in manifest files.
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key_env: Option<String>,
|
||||||
|
/// Request timeout in seconds. Tool implementation applies a safe default
|
||||||
|
/// when this is omitted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
/// Optional provider endpoint override for tests/proxies. Defaults to the
|
||||||
|
/// Brave web search endpoint for the Brave provider.
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub country: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_lang: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub ui_lang: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub safesearch: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebFetch HTTP client limits and policy.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct WebFetchConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub redirect_limit: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_response_bytes: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_bytes: Option<usize>,
|
||||||
|
/// Per-fetch escape hatch; when absent falls back to `[web]`
|
||||||
|
/// `allow_private_addresses`, then false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_private_addresses: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Memory subsystem configuration. Presence in the manifest enables
|
/// Memory subsystem configuration. Presence in the manifest enables
|
||||||
/// memory; the workspace root defaults to the Pod's pwd unless an
|
/// memory; the workspace root defaults to the Pod's pwd unless an
|
||||||
/// explicit override is given.
|
/// explicit override is given.
|
||||||
|
|
@ -560,6 +638,26 @@ permission = "write"
|
||||||
assert!(manifest.worker.top_p.is_none());
|
assert!(manifest.worker.top_p.is_none());
|
||||||
assert!(manifest.worker.top_k.is_none());
|
assert!(manifest.worker.top_k.is_none());
|
||||||
assert!(manifest.worker.stop_sequences.is_empty());
|
assert!(manifest.worker.stop_sequences.is_empty());
|
||||||
|
assert!(manifest.web.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_web_config() {
|
||||||
|
let toml = format!(
|
||||||
|
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_env = \"BRAVE_SEARCH_API_KEY\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
|
||||||
|
MINIMAL_REQUIRED
|
||||||
|
);
|
||||||
|
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||||
|
let web = manifest.web.unwrap();
|
||||||
|
assert_eq!(web.enabled, Some(true));
|
||||||
|
let search = web.search.unwrap();
|
||||||
|
assert_eq!(search.provider, Some(WebSearchProvider::Brave));
|
||||||
|
assert_eq!(search.timeout_secs, Some(12));
|
||||||
|
let fetch = web.fetch.unwrap();
|
||||||
|
assert_eq!(fetch.timeout_secs, Some(7));
|
||||||
|
assert_eq!(fetch.redirect_limit, Some(3));
|
||||||
|
assert_eq!(fetch.max_response_bytes, Some(12345));
|
||||||
|
assert_eq!(fetch.max_output_bytes, Some(2048));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,16 @@ pub struct ModelManifest {
|
||||||
/// `default_capability` → scheme 既定の順で解決される。
|
/// `default_capability` → scheme 既定の順で解決される。
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub capability: Option<ModelCapability>,
|
pub capability: Option<ModelCapability>,
|
||||||
/// モデルのコンテキストウィンドウ上限(tokens)。カタログ未掲載 / inline
|
/// モデルの希望コンテキストウィンドウ(tokens)。カタログ未掲載 / inline
|
||||||
/// モデルでもここで明示 override できる。
|
/// モデルでもここで明示 override できる。実効値は `max_context_window`
|
||||||
|
/// またはカタログ上の backend maximum で clamp される。
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub context_window: Option<u64>,
|
pub context_window: Option<u64>,
|
||||||
|
/// backend が実際に受け付けるコンテキストウィンドウ上限(tokens)。
|
||||||
|
/// 表示・安全判定に使う実効 context window は `context_window` とこの値の
|
||||||
|
/// 小さい方になる。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_context_window: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelManifest {
|
impl ModelManifest {
|
||||||
|
|
@ -70,6 +76,7 @@ impl ModelManifest {
|
||||||
auth: upper.auth.or(self.auth),
|
auth: upper.auth.or(self.auth),
|
||||||
capability: upper.capability.or(self.capability),
|
capability: upper.capability.or(self.capability),
|
||||||
context_window: upper.context_window.or(self.context_window),
|
context_window: upper.context_window.or(self.context_window),
|
||||||
|
max_context_window: upper.max_context_window.or(self.max_context_window),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,7 @@ where
|
||||||
let session_id_for_usage = pod.segment_id().to_string();
|
let session_id_for_usage = pod.segment_id().to_string();
|
||||||
let scope_change_sink = pod.scope_change_sink();
|
let scope_change_sink = pod.scope_change_sink();
|
||||||
let memory_config = pod.manifest().memory.clone();
|
let memory_config = pod.manifest().memory.clone();
|
||||||
|
let web_config = pod.manifest().web.clone();
|
||||||
let spawner_name = pod.manifest().pod.name.clone();
|
let spawner_name = pod.manifest().pod.name.clone();
|
||||||
let spawner_model = pod.manifest().model.clone();
|
let spawner_model = pod.manifest().model.clone();
|
||||||
let pod_store = pod.store().clone();
|
let pod_store = pod.store().clone();
|
||||||
|
|
@ -521,6 +522,7 @@ where
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
task_store,
|
task_store,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
|
web_config,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Memory subsystem opt-in. When `[memory]` is present in the
|
// Memory subsystem opt-in. When `[memory]` is present in the
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::CompactState;
|
||||||
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use session_store::{SystemItem, SystemReminder};
|
use session_store::{SystemItem, SystemReminder};
|
||||||
use tools::{TaskEntry, TaskStatus, TaskStore};
|
use tools::{TaskEntry, TaskStatus, TaskStore};
|
||||||
|
|
||||||
|
|
@ -37,8 +38,8 @@ use llm_worker::token_counter::total_tokens;
|
||||||
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
|
||||||
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
|
||||||
|
|
||||||
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 8;
|
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 24;
|
||||||
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 8;
|
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 24;
|
||||||
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -91,6 +92,10 @@ pub(crate) struct PodInterceptor {
|
||||||
/// per-request `context` to estimate current occupancy for threshold
|
/// per-request `context` to estimate current occupancy for threshold
|
||||||
/// checks. `None` when compaction is disabled (both thresholds unset).
|
/// checks. `None` when compaction is disabled (both thresholds unset).
|
||||||
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
|
||||||
|
/// In-flight usage records observed during the current run but not yet
|
||||||
|
/// persisted into `usage_history`. Subsequent tool-loop LLM calls must
|
||||||
|
/// see these records during pre-request safety accounting.
|
||||||
|
usage_tracker: Option<Arc<UsageTracker>>,
|
||||||
/// Pending-notification buffer drained into `worker.history`
|
/// Pending-notification buffer drained into `worker.history`
|
||||||
/// via [`Self::pending_history_appends`] just before the next LLM
|
/// via [`Self::pending_history_appends`] just before the next LLM
|
||||||
/// request. The Worker `extend`s these into its persistent history
|
/// request. The Worker `extend`s these into its persistent history
|
||||||
|
|
@ -138,6 +143,7 @@ impl PodInterceptor {
|
||||||
registry,
|
registry,
|
||||||
compact_state,
|
compact_state,
|
||||||
usage_history,
|
usage_history,
|
||||||
|
usage_tracker: None,
|
||||||
pending_notifies,
|
pending_notifies,
|
||||||
pending_attachments,
|
pending_attachments,
|
||||||
task_store,
|
task_store,
|
||||||
|
|
@ -149,6 +155,11 @@ impl PodInterceptor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
|
||||||
|
self.usage_tracker = Some(usage_tracker);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
|
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
|
||||||
/// entry through the attached writer (no-op when no writer is
|
/// entry through the attached writer (no-op when no writer is
|
||||||
/// wired). Sync — writes complete before the matching
|
/// wired). Sync — writes complete before the matching
|
||||||
|
|
@ -175,7 +186,10 @@ impl PodInterceptor {
|
||||||
/// `usage_history` is not attached (compaction fully disabled).
|
/// `usage_history` is not attached (compaction fully disabled).
|
||||||
fn estimated_tokens(&self, context: &[Item]) -> Option<u64> {
|
fn estimated_tokens(&self, context: &[Item]) -> Option<u64> {
|
||||||
let handle = self.usage_history.as_ref()?;
|
let handle = self.usage_history.as_ref()?;
|
||||||
let records = handle.lock().expect("usage_history poisoned").clone();
|
let mut records = handle.lock().expect("usage_history poisoned").clone();
|
||||||
|
if let Some(tracker) = self.usage_tracker.as_ref() {
|
||||||
|
records.extend(tracker.records());
|
||||||
|
}
|
||||||
Some(total_tokens(context, &records).tokens)
|
Some(total_tokens(context, &records).tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,9 +319,15 @@ impl Interceptor for PodInterceptor {
|
||||||
if !state.is_disabled() && !state.just_compacted() {
|
if !state.is_disabled() && !state.just_compacted() {
|
||||||
let current = current_tokens.unwrap_or(0);
|
let current = current_tokens.unwrap_or(0);
|
||||||
if state.exceeds_request(current) {
|
if state.exceeds_request(current) {
|
||||||
|
let shape = context_shape(context);
|
||||||
info!(
|
info!(
|
||||||
input_tokens = current,
|
input_tokens = current,
|
||||||
threshold = state.request_threshold().unwrap_or(0),
|
threshold = state.request_threshold().unwrap_or(0),
|
||||||
|
items_len = shape.items_len,
|
||||||
|
items_json_bytes = shape.items_json_bytes,
|
||||||
|
reasoning_items = shape.reasoning_items,
|
||||||
|
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
|
||||||
|
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
|
||||||
"Between-requests compaction threshold exceeded, yielding"
|
"Between-requests compaction threshold exceeded, yielding"
|
||||||
);
|
);
|
||||||
return PreRequestAction::Yield;
|
return PreRequestAction::Yield;
|
||||||
|
|
@ -400,6 +420,37 @@ impl Interceptor for PodInterceptor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ContextShape {
|
||||||
|
items_len: usize,
|
||||||
|
items_json_bytes: Option<usize>,
|
||||||
|
reasoning_items: usize,
|
||||||
|
reasoning_encrypted_content_count: usize,
|
||||||
|
reasoning_encrypted_content_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context_shape(context: &[Item]) -> ContextShape {
|
||||||
|
let mut shape = ContextShape {
|
||||||
|
items_len: context.len(),
|
||||||
|
items_json_bytes: serde_json::to_vec(context).ok().map(|bytes| bytes.len()),
|
||||||
|
reasoning_items: 0,
|
||||||
|
reasoning_encrypted_content_count: 0,
|
||||||
|
reasoning_encrypted_content_bytes: 0,
|
||||||
|
};
|
||||||
|
for item in context {
|
||||||
|
if let Item::Reasoning {
|
||||||
|
encrypted_content, ..
|
||||||
|
} = item
|
||||||
|
{
|
||||||
|
shape.reasoning_items += 1;
|
||||||
|
if let Some(encrypted) = encrypted_content {
|
||||||
|
shape.reasoning_encrypted_content_count += 1;
|
||||||
|
shape.reasoning_encrypted_content_bytes += encrypted.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shape
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_message_text(item: &Item) -> Option<String> {
|
fn extract_message_text(item: &Item) -> Option<String> {
|
||||||
match item {
|
match item {
|
||||||
Item::Message { content, .. } => Some(
|
Item::Message { content, .. } => Some(
|
||||||
|
|
@ -528,6 +579,40 @@ mod tests {
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 0);
|
assert_eq!(count.load(Ordering::Relaxed), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_llm_request_counts_in_flight_usage_records() {
|
||||||
|
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||||
|
let state = Arc::new(CompactState::new(None, Some(100), 2));
|
||||||
|
let ctx_items = vec![Item::user_message("hi")];
|
||||||
|
let history = usage_handle_with(ctx_items.len(), 50);
|
||||||
|
let usage_tracker = Arc::new(UsageTracker::new());
|
||||||
|
usage_tracker.note_request(ctx_items.len());
|
||||||
|
usage_tracker.record_usage(&llm_worker::event::UsageEvent {
|
||||||
|
input_tokens: Some(150),
|
||||||
|
output_tokens: Some(0),
|
||||||
|
total_tokens: Some(150),
|
||||||
|
cache_read_input_tokens: Some(0),
|
||||||
|
cache_creation_input_tokens: Some(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
let interceptor = PodInterceptor::new(
|
||||||
|
registry,
|
||||||
|
Some(state),
|
||||||
|
Some(history),
|
||||||
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
TaskStore::new(),
|
||||||
|
Arc::new(TaskReminderState::new()),
|
||||||
|
PromptCatalog::builtins_only().unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_usage_tracker(usage_tracker);
|
||||||
|
let mut ctx = ctx_items;
|
||||||
|
let action = interceptor.pre_llm_request(&mut ctx).await;
|
||||||
|
|
||||||
|
assert!(matches!(action, PreRequestAction::Yield));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_runs_hooks_when_under_threshold() {
|
async fn pre_llm_request_runs_hooks_when_under_threshold() {
|
||||||
let count = Arc::new(AtomicUsize::new(0));
|
let count = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
|
||||||
|
|
||||||
fn permission_target(arguments: &Value) -> String {
|
fn permission_target(arguments: &Value) -> String {
|
||||||
if let Value::Object(map) = arguments {
|
if let Value::Object(map) = arguments {
|
||||||
for key in ["command", "file_path", "path", "pattern"] {
|
for key in ["command", "file_path", "path", "pattern", "query", "url"] {
|
||||||
if let Some(value) = map.get(key).and_then(Value::as_str) {
|
if let Some(value) = map.get(key).and_then(Value::as_str) {
|
||||||
return value.to_string();
|
return value.to_string();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1270,7 +1270,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.task_reminder_state.clone(),
|
self.task_reminder_state.clone(),
|
||||||
self.prompts.clone(),
|
self.prompts.clone(),
|
||||||
self.log_writer.clone(),
|
self.log_writer.clone(),
|
||||||
);
|
)
|
||||||
|
.with_usage_tracker(self.usage_tracker.clone());
|
||||||
self.worker_mut().set_interceptor(interceptor);
|
self.worker_mut().set_interceptor(interceptor);
|
||||||
self.interceptor_installed = true;
|
self.interceptor_installed = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,14 @@ pub struct ModelEntry {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub capability: Option<ModelCapability>,
|
pub capability: Option<ModelCapability>,
|
||||||
/// モデル単位の context window。省略時は provider default → builtin
|
/// モデル単位の context window。省略時は provider default → builtin
|
||||||
/// fallback にフォールバックする。
|
/// fallback にフォールバックする。実効値は `max_context_window` で clamp
|
||||||
|
/// される。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub context_window: Option<u64>,
|
pub context_window: Option<u64>,
|
||||||
|
/// backend が実際に受け付ける context window の上限。UI や pre-request
|
||||||
|
/// safety は希望値ではなく clamp 済みの実効値を使う。
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_context_window: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解決済みモデル設定。`build_client` が消費する完成形。
|
/// 解決済みモデル設定。`build_client` が消費する完成形。
|
||||||
|
|
@ -130,7 +135,10 @@ pub struct ModelConfig {
|
||||||
pub model_id: String,
|
pub model_id: String,
|
||||||
pub auth: AuthRef,
|
pub auth: AuthRef,
|
||||||
pub capability: Option<ModelCapability>,
|
pub capability: Option<ModelCapability>,
|
||||||
|
/// Effective context window after backend maximum clamping.
|
||||||
pub context_window: u64,
|
pub context_window: u64,
|
||||||
|
/// Backend maximum that constrained `context_window`, when known.
|
||||||
|
pub max_context_window: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -259,7 +267,8 @@ fn split_ref(s: &str) -> Option<(&str, &str)> {
|
||||||
/// manifest 明示 > model catalog > provider.default_capability >
|
/// manifest 明示 > model catalog > provider.default_capability >
|
||||||
/// (`build_client` 側で)`Scheme::default_capability()`。
|
/// (`build_client` 側で)`Scheme::default_capability()`。
|
||||||
/// context_window は manifest 明示 > model catalog > provider default >
|
/// context_window は manifest 明示 > model catalog > provider default >
|
||||||
/// [`DEFAULT_CONTEXT_WINDOW`]。
|
/// [`DEFAULT_CONTEXT_WINDOW`]。実効 context_window は manifest/model の
|
||||||
|
/// max_context_window で clamp される。
|
||||||
pub fn resolve_model_manifest(manifest: &ModelManifest) -> Result<ModelConfig, ResolveError> {
|
pub fn resolve_model_manifest(manifest: &ModelManifest) -> Result<ModelConfig, ResolveError> {
|
||||||
let providers = load_providers().map_err(ResolveError::LoadProviders)?;
|
let providers = load_providers().map_err(ResolveError::LoadProviders)?;
|
||||||
let models = load_models().map_err(ResolveError::LoadModels)?;
|
let models = load_models().map_err(ResolveError::LoadModels)?;
|
||||||
|
|
@ -310,11 +319,15 @@ pub fn resolve_with_catalogs(
|
||||||
.and_then(|m| m.capability.clone())
|
.and_then(|m| m.capability.clone())
|
||||||
.or_else(|| provider.default_capability.clone())
|
.or_else(|| provider.default_capability.clone())
|
||||||
});
|
});
|
||||||
let context_window = manifest
|
let desired_context_window = manifest
|
||||||
.context_window
|
.context_window
|
||||||
.or_else(|| model_entry.and_then(|m| m.context_window))
|
.or_else(|| model_entry.and_then(|m| m.context_window))
|
||||||
.or(provider.default_context_window)
|
.or(provider.default_context_window)
|
||||||
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
|
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
|
||||||
|
let max_context_window = manifest
|
||||||
|
.max_context_window
|
||||||
|
.or_else(|| model_entry.and_then(|m| m.max_context_window));
|
||||||
|
let context_window = clamp_context_window(desired_context_window, max_context_window);
|
||||||
Ok(ModelConfig {
|
Ok(ModelConfig {
|
||||||
scheme,
|
scheme,
|
||||||
base_url,
|
base_url,
|
||||||
|
|
@ -322,6 +335,7 @@ pub fn resolve_with_catalogs(
|
||||||
auth,
|
auth,
|
||||||
capability,
|
capability,
|
||||||
context_window,
|
context_window,
|
||||||
|
max_context_window,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
let scheme = manifest
|
let scheme = manifest
|
||||||
|
|
@ -335,17 +349,24 @@ pub fn resolve_with_catalogs(
|
||||||
.auth
|
.auth
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or(ResolveError::InlineMissing("auth"))?;
|
.ok_or(ResolveError::InlineMissing("auth"))?;
|
||||||
|
let desired_context_window = manifest.context_window.unwrap_or(DEFAULT_CONTEXT_WINDOW);
|
||||||
|
let max_context_window = manifest.max_context_window;
|
||||||
Ok(ModelConfig {
|
Ok(ModelConfig {
|
||||||
scheme,
|
scheme,
|
||||||
base_url: manifest.base_url.clone(),
|
base_url: manifest.base_url.clone(),
|
||||||
model_id,
|
model_id,
|
||||||
auth,
|
auth,
|
||||||
capability: manifest.capability.clone(),
|
capability: manifest.capability.clone(),
|
||||||
context_window: manifest.context_window.unwrap_or(DEFAULT_CONTEXT_WINDOW),
|
context_window: clamp_context_window(desired_context_window, max_context_window),
|
||||||
|
max_context_window,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clamp_context_window(desired: u64, max: Option<u64>) -> u64 {
|
||||||
|
max.map(|limit| desired.min(limit)).unwrap_or(desired)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -420,6 +441,52 @@ mod tests {
|
||||||
assert_eq!(cfg.context_window, 123_456);
|
assert_eq!(cfg.context_window, 123_456);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_window_is_clamped_by_catalog_backend_max() {
|
||||||
|
let providers = load_builtin_providers().unwrap();
|
||||||
|
let models = load_builtin_models().unwrap();
|
||||||
|
let manifest = ModelManifest {
|
||||||
|
ref_: Some("codex-oauth/gpt-5.5".into()),
|
||||||
|
context_window: Some(1_000_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||||
|
assert_eq!(cfg.context_window, 272_000);
|
||||||
|
assert_eq!(cfg.max_context_window, Some(272_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_context_window_is_clamped_by_manifest_backend_max() {
|
||||||
|
let providers = load_builtin_providers().unwrap();
|
||||||
|
let models = load_builtin_models().unwrap();
|
||||||
|
let manifest = ModelManifest {
|
||||||
|
scheme: Some(SchemeKind::Anthropic),
|
||||||
|
model_id: Some("custom".into()),
|
||||||
|
auth: Some(AuthRef::None),
|
||||||
|
context_window: Some(1_000_000),
|
||||||
|
max_context_window: Some(272_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||||
|
assert_eq!(cfg.context_window, 272_000);
|
||||||
|
assert_eq!(cfg.max_context_window, Some(272_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_backend_max_overrides_catalog_backend_max() {
|
||||||
|
let providers = load_builtin_providers().unwrap();
|
||||||
|
let models = load_builtin_models().unwrap();
|
||||||
|
let manifest = ModelManifest {
|
||||||
|
ref_: Some("codex-oauth/gpt-5.5".into()),
|
||||||
|
context_window: Some(1_000_000),
|
||||||
|
max_context_window: Some(500_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
|
||||||
|
assert_eq!(cfg.context_window, 500_000);
|
||||||
|
assert_eq!(cfg.max_context_window, Some(500_000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_ref_with_inline_overrides() {
|
fn resolve_ref_with_inline_overrides() {
|
||||||
let providers = load_builtin_providers().unwrap();
|
let providers = load_builtin_providers().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
capability: None,
|
capability: None,
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
|
max_context_window: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,6 +316,7 @@ mod tests {
|
||||||
auth: AuthRef::None,
|
auth: AuthRef::None,
|
||||||
capability: None,
|
capability: None,
|
||||||
context_window: 200_000,
|
context_window: 200_000,
|
||||||
|
max_context_window: None,
|
||||||
};
|
};
|
||||||
assert!(build_client_from_config(&config).is_ok());
|
assert!(build_client_from_config(&config).is_ok());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ grep-searcher = "0.1.16"
|
||||||
ignore = "0.4.25"
|
ignore = "0.4.25"
|
||||||
llm-worker = { workspace = true }
|
llm-worker = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ mod edit;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod grep;
|
mod grep;
|
||||||
mod read;
|
mod read;
|
||||||
|
mod web;
|
||||||
mod write;
|
mod write;
|
||||||
|
|
||||||
pub use bash::bash_tool;
|
pub use bash::bash_tool;
|
||||||
|
|
@ -39,6 +40,7 @@ pub use read::read_tool;
|
||||||
pub use scoped_fs::ScopedFs;
|
pub use scoped_fs::ScopedFs;
|
||||||
pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools};
|
pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools};
|
||||||
pub use tracker::Tracker;
|
pub use tracker::Tracker;
|
||||||
|
pub use web::{web_fetch_tool, web_search_tool};
|
||||||
pub use write::write_tool;
|
pub use write::write_tool;
|
||||||
|
|
||||||
/// Register all builtin tools, wiring them to a shared `ScopedFs`
|
/// Register all builtin tools, wiring them to a shared `ScopedFs`
|
||||||
|
|
@ -57,6 +59,7 @@ pub fn builtin_tools(
|
||||||
tracker: Tracker,
|
tracker: Tracker,
|
||||||
task_store: TaskStore,
|
task_store: TaskStore,
|
||||||
bash_output_dir: std::path::PathBuf,
|
bash_output_dir: std::path::PathBuf,
|
||||||
|
web_config: Option<manifest::WebConfig>,
|
||||||
) -> Vec<llm_worker::tool::ToolDefinition> {
|
) -> Vec<llm_worker::tool::ToolDefinition> {
|
||||||
let mut defs = vec![
|
let mut defs = vec![
|
||||||
read_tool(fs.clone(), tracker.clone()),
|
read_tool(fs.clone(), tracker.clone()),
|
||||||
|
|
@ -65,6 +68,8 @@ pub fn builtin_tools(
|
||||||
glob_tool(fs.clone()),
|
glob_tool(fs.clone()),
|
||||||
grep_tool(fs.clone()),
|
grep_tool(fs.clone()),
|
||||||
bash_tool(fs, bash_output_dir),
|
bash_tool(fs, bash_output_dir),
|
||||||
|
web_search_tool(web::WebTools::new(web_config.clone())),
|
||||||
|
web_fetch_tool(web::WebTools::new(web_config)),
|
||||||
];
|
];
|
||||||
defs.extend(task_tools(task_store));
|
defs.extend(task_tools(task_store));
|
||||||
defs
|
defs
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
//! let tracker = Tracker::new(); // session lifetime
|
//! let tracker = Tracker::new(); // session lifetime
|
||||||
//! let bash_outputs = PathBuf::from("/run/insomnia/bash-output");
|
//! let bash_outputs = PathBuf::from("/run/insomnia/bash-output");
|
||||||
//! let task_store = tools::TaskStore::new();
|
//! let task_store = tools::TaskStore::new();
|
||||||
//! let defs = builtin_tools(fs, tracker, task_store, bash_outputs);
|
//! let defs = builtin_tools(fs, tracker, task_store, bash_outputs, None);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
|
||||||
1082
crates/tools/src/web.rs
Normal file
1082
crates/tools/src/web.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -48,6 +48,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||||
tracker,
|
tracker,
|
||||||
TaskStore::new(),
|
TaskStore::new(),
|
||||||
spill.path().to_path_buf(),
|
spill.path().to_path_buf(),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
(dir, spill, reg)
|
(dir, spill, reg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||||
tracker,
|
tracker,
|
||||||
TaskStore::new(),
|
TaskStore::new(),
|
||||||
spill.path().to_path_buf(),
|
spill.path().to_path_buf(),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
(dir, spill, reg)
|
(dir, spill, reg)
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +95,8 @@ fn builtin_tools_registers_full_set() {
|
||||||
"TaskGet",
|
"TaskGet",
|
||||||
"TaskList",
|
"TaskList",
|
||||||
"TaskUpdate",
|
"TaskUpdate",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
"Write"
|
"Write"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -289,7 +292,7 @@ async fn edit_requires_read_across_tools() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deterministic_tool_order_is_registration_order() {
|
async fn deterministic_tool_order_is_registration_order() {
|
||||||
let (_dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
// Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, TaskCreate, TaskList, TaskGet, TaskUpdate
|
// Registration order from builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch, TaskCreate, TaskList, TaskGet, TaskUpdate
|
||||||
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
names,
|
names,
|
||||||
|
|
@ -300,6 +303,8 @@ async fn deterministic_tool_order_is_registration_order() {
|
||||||
"Glob",
|
"Glob",
|
||||||
"Grep",
|
"Grep",
|
||||||
"Bash",
|
"Bash",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
"TaskCreate",
|
"TaskCreate",
|
||||||
"TaskList",
|
"TaskList",
|
||||||
"TaskGet",
|
"TaskGet",
|
||||||
|
|
@ -319,6 +324,8 @@ fn tool_names_match_reference_spec() {
|
||||||
"Glob",
|
"Glob",
|
||||||
"Grep",
|
"Grep",
|
||||||
"Bash",
|
"Bash",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch",
|
||||||
"TaskCreate",
|
"TaskCreate",
|
||||||
"TaskList",
|
"TaskList",
|
||||||
"TaskGet",
|
"TaskGet",
|
||||||
|
|
@ -344,6 +351,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
TaskStore::new(),
|
TaskStore::new(),
|
||||||
spill.path().to_path_buf(),
|
spill.path().to_path_buf(),
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
|
|
||||||
let a = dir.path().join("a.txt");
|
let a = dir.path().join("a.txt");
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,81 @@ impl QueuedInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_INPUT_HISTORY_LIMIT: usize = 100;
|
||||||
|
|
||||||
|
struct ComposerInputHistory {
|
||||||
|
entries: VecDeque<Vec<Segment>>,
|
||||||
|
browse: Option<ComposerInputHistoryBrowse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposerInputHistoryBrowse {
|
||||||
|
index: usize,
|
||||||
|
draft: Vec<Segment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComposerInputHistory {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: VecDeque::new(),
|
||||||
|
browse: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, segments: Vec<Segment>) {
|
||||||
|
if segments_are_blank(&segments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.browse = None;
|
||||||
|
if self.entries.back() == Some(&segments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
||||||
|
self.entries.pop_front();
|
||||||
|
}
|
||||||
|
self.entries.push_back(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_browsing(&self) -> bool {
|
||||||
|
self.browse.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_older(&mut self, draft: Vec<Segment>) -> Option<Vec<Segment>> {
|
||||||
|
if self.entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = match self.browse.as_mut() {
|
||||||
|
Some(browse) => {
|
||||||
|
if browse.index > 0 {
|
||||||
|
browse.index -= 1;
|
||||||
|
}
|
||||||
|
browse.index
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let index = self.entries.len() - 1;
|
||||||
|
self.browse = Some(ComposerInputHistoryBrowse { index, draft });
|
||||||
|
index
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.entries.get(index).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_newer(&mut self) -> Option<Vec<Segment>> {
|
||||||
|
let browse = self.browse.as_mut()?;
|
||||||
|
if browse.index + 1 < self.entries.len() {
|
||||||
|
browse.index += 1;
|
||||||
|
return self.entries.get(browse.index).cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.browse.take().map(|browse| browse.draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_browse(&mut self) {
|
||||||
|
self.browse = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum ActionbarNoticeLevel {
|
pub enum ActionbarNoticeLevel {
|
||||||
|
|
@ -205,6 +280,9 @@ pub struct App {
|
||||||
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
||||||
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
||||||
queued_inputs: VecDeque<QueuedInput>,
|
queued_inputs: VecDeque<QueuedInput>,
|
||||||
|
/// TUI-local readline-style composer input history. This is intentionally
|
||||||
|
/// client-side only: recalled entries are plain drafts until submitted again.
|
||||||
|
input_history: ComposerInputHistory,
|
||||||
/// Local submit state kept until the accepted run either completes
|
/// Local submit state kept until the accepted run either completes
|
||||||
/// normally or reports that the empty assistant turn was rolled back.
|
/// normally or reports that the empty assistant turn was rolled back.
|
||||||
pending_submit_rollback: Option<RollbackSubmitState>,
|
pending_submit_rollback: Option<RollbackSubmitState>,
|
||||||
|
|
@ -251,6 +329,7 @@ impl App {
|
||||||
task_pane_open: false,
|
task_pane_open: false,
|
||||||
task_pane_scroll: 0,
|
task_pane_scroll: 0,
|
||||||
queued_inputs: VecDeque::new(),
|
queued_inputs: VecDeque::new(),
|
||||||
|
input_history: ComposerInputHistory::new(),
|
||||||
pending_submit_rollback: None,
|
pending_submit_rollback: None,
|
||||||
last_rolled_back_input: None,
|
last_rolled_back_input: None,
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +444,7 @@ impl App {
|
||||||
// `prefix_start` indexes the sigil atom; the text we want to
|
// `prefix_start` indexes the sigil atom; the text we want to
|
||||||
// replace lives just after it (sigil itself stays).
|
// replace lives just after it (sigil itself stays).
|
||||||
let typed_start = state.prefix_start + 1;
|
let typed_start = state.prefix_start + 1;
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.replace_with_text_at(typed_start, &text);
|
self.input.replace_with_text_at(typed_start, &text);
|
||||||
self.refresh_completion()
|
self.refresh_completion()
|
||||||
}
|
}
|
||||||
|
|
@ -419,6 +499,7 @@ impl App {
|
||||||
};
|
};
|
||||||
let kind = state.kind;
|
let kind = state.kind;
|
||||||
let start = state.prefix_start;
|
let start = state.prefix_start;
|
||||||
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||||
|
|
@ -453,6 +534,7 @@ impl App {
|
||||||
let kind = state.kind;
|
let kind = state.kind;
|
||||||
let start = state.prefix_start;
|
let start = state.prefix_start;
|
||||||
let value = entry.value.clone();
|
let value = entry.value.clone();
|
||||||
|
self.input_history.cancel_browse();
|
||||||
match kind {
|
match kind {
|
||||||
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
||||||
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
||||||
|
|
@ -468,11 +550,13 @@ impl App {
|
||||||
// Empty Enter only does something meaningful when the Pod
|
// Empty Enter only does something meaningful when the Pod
|
||||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||||
if self.paused {
|
if self.paused {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
return Some(Method::Resume);
|
return Some(Method::Resume);
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
self.input_history.record(segments.clone());
|
||||||
if self.running {
|
if self.running {
|
||||||
self.queued_inputs.push_back(QueuedInput::new(segments));
|
self.queued_inputs.push_back(QueuedInput::new(segments));
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
|
|
@ -503,6 +587,44 @@ impl App {
|
||||||
self.queued_inputs.len()
|
self.queued_inputs.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn input_history_len(&self) -> usize {
|
||||||
|
self.input_history.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_history_is_browsing(&self) -> bool {
|
||||||
|
self.input_history.is_browsing()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_browse_input_history_older(&self) -> bool {
|
||||||
|
self.input_history.is_browsing() || self.input.cursor_at_start()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_browse_input_history_newer(&self) -> bool {
|
||||||
|
self.input_history.is_browsing() && self.input.cursor_at_end()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browse_input_history_older(&mut self) -> bool {
|
||||||
|
if self.input_history.entries.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let draft = self.input.submit_segments();
|
||||||
|
let Some(segments) = self.input_history.browse_older(draft) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.input.replace_with_segments(&segments);
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browse_input_history_newer(&mut self) -> bool {
|
||||||
|
let Some(segments) = self.input_history.browse_newer() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.input.replace_with_segments(&segments);
|
||||||
|
self.completion = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn flash_actionbar_notice(
|
pub fn flash_actionbar_notice(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: impl Into<String>,
|
text: impl Into<String>,
|
||||||
|
|
@ -566,6 +688,7 @@ impl App {
|
||||||
let Some(queued) = self.queued_inputs.pop_front() else {
|
let Some(queued) = self.queued_inputs.pop_front() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.replace_with_segments(&queued.segments);
|
self.input.replace_with_segments(&queued.segments);
|
||||||
self.completion = None;
|
self.completion = None;
|
||||||
true
|
true
|
||||||
|
|
@ -1512,6 +1635,9 @@ impl App {
|
||||||
// keeping the normal composer buffer intact.
|
// keeping the normal composer buffer intact.
|
||||||
pub fn insert_char(&mut self, c: char) {
|
pub fn insert_char(&mut self, c: char) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().insert_char(c);
|
self.active_input_mut().insert_char(c);
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1519,6 +1645,9 @@ impl App {
|
||||||
}
|
}
|
||||||
pub fn insert_newline(&mut self) {
|
pub fn insert_newline(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().insert_newline();
|
self.active_input_mut().insert_newline();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1529,11 +1658,15 @@ impl App {
|
||||||
self.command_input.insert_str(&content);
|
self.command_input.insert_str(&content);
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
} else {
|
} else {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
self.input.insert_paste(content);
|
self.input.insert_paste(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn delete_char_before(&mut self) {
|
pub fn delete_char_before(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().delete_before();
|
self.active_input_mut().delete_before();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -1541,6 +1674,9 @@ impl App {
|
||||||
}
|
}
|
||||||
pub fn delete_char_after(&mut self) {
|
pub fn delete_char_after(&mut self) {
|
||||||
let command_mode = self.is_command_mode();
|
let command_mode = self.is_command_mode();
|
||||||
|
if !command_mode {
|
||||||
|
self.input_history.cancel_browse();
|
||||||
|
}
|
||||||
self.active_input_mut().delete_after();
|
self.active_input_mut().delete_after();
|
||||||
if command_mode {
|
if command_mode {
|
||||||
self.command_completion_selected = None;
|
self.command_completion_selected = None;
|
||||||
|
|
@ -2781,6 +2917,124 @@ mod completion_flow_tests {
|
||||||
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
|
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.running = true;
|
||||||
|
|
||||||
|
for c in "repeat".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
assert_eq!(app.queued_input_count(), 1);
|
||||||
|
|
||||||
|
for c in "repeat".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
assert_eq!(app.queued_input_count(), 2);
|
||||||
|
|
||||||
|
app.insert_char(' ');
|
||||||
|
assert!(app.submit_input().is_none());
|
||||||
|
assert_eq!(app.input_history_len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_preserves_typed_segments() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
let original = vec![
|
||||||
|
Segment::Text {
|
||||||
|
content: "see ".into(),
|
||||||
|
},
|
||||||
|
Segment::FileRef {
|
||||||
|
path: "src/main.rs".into(),
|
||||||
|
},
|
||||||
|
Segment::Text {
|
||||||
|
content: " and ".into(),
|
||||||
|
},
|
||||||
|
Segment::KnowledgeRef {
|
||||||
|
slug: "design-note".into(),
|
||||||
|
},
|
||||||
|
Segment::Text {
|
||||||
|
content: " then ".into(),
|
||||||
|
},
|
||||||
|
Segment::WorkflowInvoke {
|
||||||
|
slug: "review".into(),
|
||||||
|
},
|
||||||
|
Segment::Paste {
|
||||||
|
id: 1,
|
||||||
|
chars: 13,
|
||||||
|
lines: 1,
|
||||||
|
content: "literal paste".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
app.input.replace_with_segments(&original);
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert_eq!(app.input.submit_segments(), original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_history_restores_non_empty_draft_after_newest() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "sent".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
for c in "draft".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert_eq!(input_text(&app), "sent");
|
||||||
|
assert!(app.browse_input_history_newer());
|
||||||
|
assert_eq!(input_text(&app), "draft");
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_recalled_input_exits_history_browse_mode() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "sent".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert!(app.input_history_is_browsing());
|
||||||
|
app.insert_char('!');
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
assert_eq!(input_text(&app), "sent!");
|
||||||
|
assert!(!app.browse_input_history_newer());
|
||||||
|
assert_eq!(input_text(&app), "sent!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submitting_recalled_history_sends_normally_and_records_if_not_duplicate() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
for c in "first".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
for c in "second".chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
assert!(app.browse_input_history_older());
|
||||||
|
let method = app.submit_input();
|
||||||
|
match method {
|
||||||
|
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"),
|
||||||
|
other => panic!("expected recalled run, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(app.input_history_len(), 3);
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_pane_toggle_flips_state_and_resets_scroll() {
|
fn task_pane_toggle_flips_state_and_resets_scroll() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,14 @@ impl InputBuffer {
|
||||||
self.atoms.is_empty()
|
self.atoms.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cursor_at_start(&self) -> bool {
|
||||||
|
self.cursor == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor_at_end(&self) -> bool {
|
||||||
|
self.cursor == self.atoms.len()
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace the whole composer with protocol segments previously emitted
|
/// Replace the whole composer with protocol segments previously emitted
|
||||||
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
||||||
/// and placing the cursor at the end of the restored input.
|
/// and placing the cursor at the end of the restored input.
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,7 @@ async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.reload().await?;
|
app.reload_or_notice().await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -972,12 +972,20 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
app.move_cursor_up();
|
if app.can_browse_input_history_older() && app.browse_input_history_older() {
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
|
} else {
|
||||||
|
app.move_cursor_up();
|
||||||
|
app.refresh_completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
app.move_cursor_down();
|
if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
|
||||||
app.refresh_completion()
|
app.refresh_completion()
|
||||||
|
} else {
|
||||||
|
app.move_cursor_down();
|
||||||
|
app.refresh_completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
app.move_cursor_home();
|
app.move_cursor_home();
|
||||||
|
|
@ -1923,6 +1931,70 @@ mod tests {
|
||||||
assert_eq!(input_text(&app), "hello");
|
assert_eq!(input_text(&app), "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_at_start_with_empty_history_preserves_draft_without_browsing() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "draft");
|
||||||
|
app.move_cursor_start();
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "draft");
|
||||||
|
assert!(!app.input_history_is_browsing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_from_empty_composer_recalls_history_and_down_restores_empty_draft() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "first");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
type_keys(&mut app, "second");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "second");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "first");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "second");
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Down)).is_none());
|
||||||
|
assert_eq!(input_text(&app), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_inside_multiline_preserves_existing_cursor_up_behavior() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "ab\ncd");
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Char('X'))).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "abX\ncd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_at_start_of_multiline_recalls_history() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
type_keys(&mut app, "sent");
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, key(KeyCode::Enter)),
|
||||||
|
Some(Method::Run { .. })
|
||||||
|
));
|
||||||
|
type_keys(&mut app, "draft\nbody");
|
||||||
|
app.move_cursor_start();
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, key(KeyCode::Up)).is_none());
|
||||||
|
|
||||||
|
assert_eq!(input_text(&app), "sent");
|
||||||
|
}
|
||||||
|
|
||||||
fn enter_command_mode(app: &mut App) {
|
fn enter_command_mode(app: &mut App) {
|
||||||
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
|
||||||
assert!(app.is_command_mode());
|
assert!(app.is_command_mode());
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, read};
|
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use protocol::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment};
|
use protocol::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
@ -25,6 +25,8 @@ use crate::pod_list::{
|
||||||
const MAX_ENTRIES: usize = 50;
|
const MAX_ENTRIES: usize = 50;
|
||||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||||
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
|
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
|
||||||
|
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) enum MultiPodError {
|
pub(crate) enum MultiPodError {
|
||||||
|
|
@ -83,8 +85,28 @@ pub(crate) async fn run(
|
||||||
return Err(MultiPodError::NoPods);
|
return Err(MultiPodError::NoPods);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut pending_reload = PendingReload::default();
|
||||||
|
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if let Some(result) = pending_reload.finish_if_ready().await {
|
||||||
|
app.apply_reload_result(result);
|
||||||
|
}
|
||||||
|
|
||||||
terminal.draw(|f| draw(f, app))?;
|
terminal.draw(|f| draw(f, app))?;
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= next_poll {
|
||||||
|
pending_reload.start();
|
||||||
|
next_poll = now + MULTI_POD_POLL_INTERVAL;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_wait = TERMINAL_EVENT_POLL_INTERVAL.min(next_poll.saturating_duration_since(now));
|
||||||
|
if !poll(event_wait)? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match read()? {
|
match read()? {
|
||||||
TermEvent::Key(key) => match app.handle_key(key) {
|
TermEvent::Key(key) => match app.handle_key(key) {
|
||||||
MultiPodAction::None => {}
|
MultiPodAction::None => {}
|
||||||
|
|
@ -94,12 +116,18 @@ pub(crate) async fn run(
|
||||||
return Ok(MultiPodOutcome::Open(request));
|
return Ok(MultiPodOutcome::Open(request));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MultiPodAction::Refresh => app.reload().await?,
|
MultiPodAction::Refresh => {
|
||||||
|
if !pending_reload.start() {
|
||||||
|
app.notice = Some("Refresh already in progress.".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
MultiPodAction::Send(request) => {
|
MultiPodAction::Send(request) => {
|
||||||
|
pending_reload.abort();
|
||||||
terminal.draw(|f| draw(f, app))?;
|
terminal.draw(|f| draw(f, app))?;
|
||||||
let result = send_run_and_confirm(&request.socket_path, request.segments).await;
|
let result = send_run_and_confirm(&request.socket_path, request.segments).await;
|
||||||
app.finish_send(result);
|
app.finish_send(result);
|
||||||
let _ = app.reload().await;
|
app.reload_or_notice().await;
|
||||||
|
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
TermEvent::Paste(text) => app.input.insert_paste(text),
|
TermEvent::Paste(text) => app.input.insert_paste(text),
|
||||||
|
|
@ -109,6 +137,64 @@ pub(crate) async fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PendingReload {
|
||||||
|
handle: Option<tokio::task::JoinHandle<Result<PodList, MultiPodError>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingReload {
|
||||||
|
fn start(&mut self) -> bool {
|
||||||
|
if self.handle.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.handle = Some(tokio::spawn(async { load_pod_list(None).await }));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn start_with_handle(
|
||||||
|
&mut self,
|
||||||
|
handle: tokio::task::JoinHandle<Result<PodList, MultiPodError>>,
|
||||||
|
) -> bool {
|
||||||
|
if self.handle.is_some() {
|
||||||
|
handle.abort();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.handle = Some(handle);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_if_ready(&mut self) -> Option<Result<PodList, MultiPodError>> {
|
||||||
|
if !self.handle.as_ref()?.is_finished() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let handle = self.handle.take()?;
|
||||||
|
Some(match handle.await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => Err(MultiPodError::Io(io::Error::other(format!(
|
||||||
|
"reload task failed: {e}"
|
||||||
|
)))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort(&mut self) {
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PendingReload {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { handle: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingReload {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_store_dir() -> Result<PathBuf, MultiPodError> {
|
fn default_store_dir() -> Result<PathBuf, MultiPodError> {
|
||||||
manifest::paths::sessions_dir().ok_or_else(|| {
|
manifest::paths::sessions_dir().ok_or_else(|| {
|
||||||
MultiPodError::Io(io::Error::new(
|
MultiPodError::Io(io::Error::new(
|
||||||
|
|
@ -151,10 +237,29 @@ impl MultiPodApp {
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn reload(&mut self) -> Result<(), MultiPodError> {
|
pub(crate) async fn reload_or_notice(&mut self) {
|
||||||
self.list = load_pod_list(self.list.selected_name.clone()).await?;
|
let result = load_pod_list(None).await;
|
||||||
|
self.apply_reload_result(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_reload_result(&mut self, result: Result<PodList, MultiPodError>) {
|
||||||
|
match result {
|
||||||
|
Ok(list) => self.apply_reloaded_list(list),
|
||||||
|
Err(error) => {
|
||||||
|
self.notice = Some(format!("Refresh failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_reloaded_list(&mut self, mut list: PodList) {
|
||||||
|
list.selected_name = self
|
||||||
|
.list
|
||||||
|
.selected_name
|
||||||
|
.clone()
|
||||||
|
.filter(|name| list.entries.iter().any(|entry| entry.name == *name))
|
||||||
|
.or_else(|| list.entries.first().map(|entry| entry.name.clone()));
|
||||||
|
self.list = list;
|
||||||
self.ensure_selection_visible();
|
self.ensure_selection_visible();
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -967,6 +1072,108 @@ mod tests {
|
||||||
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_poll_reload_preserves_selection_composer_and_notice() {
|
||||||
|
let mut app = test_app(vec![
|
||||||
|
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
||||||
|
live_info_with_updated_at("beta", PodStatus::Idle, 20),
|
||||||
|
]);
|
||||||
|
app.select_next();
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
app.input.insert_str("draft survives polling");
|
||||||
|
app.notice = Some("keep this notice".to_string());
|
||||||
|
let refreshed = PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
live_info_with_updated_at("gamma", PodStatus::Idle, 60),
|
||||||
|
live_info_with_updated_at("alpha", PodStatus::Running, 50),
|
||||||
|
live_info_with_updated_at("beta", PodStatus::Idle, 40),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.apply_reloaded_list(refreshed);
|
||||||
|
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
assert_eq!(
|
||||||
|
app.list
|
||||||
|
.selected_entry()
|
||||||
|
.unwrap()
|
||||||
|
.live
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.status,
|
||||||
|
Some(PodStatus::Running)
|
||||||
|
);
|
||||||
|
assert_eq!(input_text(&app), "draft survives polling");
|
||||||
|
assert_eq!(app.notice.as_deref(), Some("keep this notice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_poll_reload_falls_back_when_selected_pod_disappears() {
|
||||||
|
let mut app = test_app(vec![
|
||||||
|
live_info_with_updated_at("alpha", PodStatus::Idle, 10),
|
||||||
|
live_info_with_updated_at("beta", PodStatus::Running, 20),
|
||||||
|
]);
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "beta");
|
||||||
|
let refreshed = PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
vec![stopped_info_with_updated_at("closed", 30)],
|
||||||
|
vec![live_info_with_updated_at("alpha", PodStatus::Idle, 40)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.apply_reloaded_list(refreshed);
|
||||||
|
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
assert_eq!(visible_entry_indices(&app.list), vec![0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_poll_reload_error_keeps_previous_list_and_composer() {
|
||||||
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
|
app.input.insert_str("keep draft");
|
||||||
|
|
||||||
|
app.apply_reload_result(Err(MultiPodError::Io(io::Error::other("boom"))));
|
||||||
|
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
assert_eq!(input_text(&app), "keep draft");
|
||||||
|
let notice = app.notice.as_deref().unwrap();
|
||||||
|
assert!(notice.contains("Refresh failed"));
|
||||||
|
assert!(notice.contains("boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_poll_reload_does_not_overlap_in_flight_reload() {
|
||||||
|
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||||
|
let mut pending = PendingReload::default();
|
||||||
|
|
||||||
|
assert!(pending.start_with_handle(tokio::spawn(async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
Err(MultiPodError::Io(io::Error::other("boom")))
|
||||||
|
})));
|
||||||
|
assert!(!pending.start_with_handle(tokio::spawn(async {
|
||||||
|
Ok(PodList::from_sources(
|
||||||
|
PodVisibilitySource::ResumePicker,
|
||||||
|
vec![],
|
||||||
|
vec![live_info("beta", PodStatus::Idle)],
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
))
|
||||||
|
})));
|
||||||
|
assert!(pending.finish_if_ready().await.is_none());
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
let result = pending.finish_if_ready().await.unwrap();
|
||||||
|
app.apply_reload_result(result);
|
||||||
|
|
||||||
|
assert_eq!(app.list.selected_entry().unwrap().name, "alpha");
|
||||||
|
assert!(app.notice.as_deref().unwrap().contains("Refresh failed"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_idle_live_selected_target_is_send_eligible() {
|
fn multi_idle_live_selected_target_is_send_eligible() {
|
||||||
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,20 @@ tool = "Write"
|
||||||
pattern = "*.env"
|
pattern = "*.env"
|
||||||
action = "deny"
|
action = "deny"
|
||||||
|
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[web.search]
|
||||||
|
provider = "brave"
|
||||||
|
api_key_env = "BRAVE_SEARCH_API_KEY"
|
||||||
|
timeout_secs = 15
|
||||||
|
|
||||||
|
[web.fetch]
|
||||||
|
timeout_secs = 20
|
||||||
|
redirect_limit = 5
|
||||||
|
max_response_bytes = 2097152
|
||||||
|
max_output_bytes = 65536
|
||||||
|
|
||||||
[compaction]
|
[compaction]
|
||||||
prune_protected_tokens = 8000
|
prune_protected_tokens = 8000
|
||||||
prune_min_savings = 4096
|
prune_min_savings = 4096
|
||||||
|
|
@ -220,6 +234,30 @@ scheme 側が吸収する。
|
||||||
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
|
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
|
||||||
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
|
parse error になるが、provider が受け付けない値や組み合わせは API 応答で検出する。
|
||||||
|
|
||||||
|
## `[web]` 設定
|
||||||
|
|
||||||
|
`WebSearch` / `WebFetch` は通常の built-in function tool として登録されるが、manifest で明示的に有効化されるまでネットワークアクセスしない。無効または未設定の場合、tool call は「設定されていない」旨の明示的なエラーを返す。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[web.search]
|
||||||
|
provider = "brave"
|
||||||
|
api_key_env = "BRAVE_SEARCH_API_KEY" # API key は env 参照に置き、manifest に raw secret を書かない
|
||||||
|
timeout_secs = 15
|
||||||
|
|
||||||
|
[web.fetch]
|
||||||
|
timeout_secs = 20
|
||||||
|
redirect_limit = 5
|
||||||
|
max_response_bytes = 2097152
|
||||||
|
max_output_bytes = 65536
|
||||||
|
```
|
||||||
|
|
||||||
|
`WebSearch` の最初の provider は Brave Search API(`https://api.search.brave.com/res/v1/web/search`)で、入力は `query` と任意の `limit` / `offset`。Brave の制約に合わせて `query` は 400 文字 / 50 words まで、`limit` は 1-20、`offset` は 0-9 に制限される。`timeout_secs` を省略した場合は安全な既定値が使われ、provider response は固定上限内で読み込まれる。
|
||||||
|
|
||||||
|
`WebFetch` は http/https URL のみを fetch し、timeout・redirect・response/output byte limit を適用する。localhost / private / link-local などの host/IP は fetch 前と各 redirect で拒否される。テストや明示的に信頼した環境では `[web] allow_private_addresses = true` または `[web.fetch] allow_private_addresses = true` を指定できる。
|
||||||
|
|
||||||
## `[permissions]` 設定
|
## `[permissions]` 設定
|
||||||
|
|
||||||
`[permissions]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
|
`[permissions]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
|
||||||
|
|
@ -234,7 +272,7 @@ pattern = "rm *"
|
||||||
action = "deny"
|
action = "deny"
|
||||||
```
|
```
|
||||||
|
|
||||||
`tool` は実行時に登録されているツール名(`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep` 等)に対して大小文字を無視して照合する。`pattern` は built-in tool では主に `command` / `file_path` / `path` / `pattern` 引数に対する `*` / `?` ワイルドカードとして評価される。
|
`tool` は実行時に登録されているツール名(`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`, `WebSearch`, `WebFetch` 等)に対して大小文字を無視して照合する。`pattern` は built-in tool では主に `command` / `file_path` / `path` / `pattern` / `query` / `url` 引数に対する `*` / `?` ワイルドカードとして評価される。
|
||||||
|
|
||||||
`allow` は通常実行、`deny` はその tool call を実行せず `is_error = true` の synthetic tool result を履歴へ追加してターンを継続する。`ask` は型として受け付けるが、承認 protocol は未実装のため現在は headless に待機せず fail-closed(synthetic error result)になる。
|
`allow` は通常実行、`deny` はその tool call を実行せず `is_error = true` の synthetic tool result を履歴へ追加してターンを継続する。`ask` は型として受け付けるが、承認 protocol は未実装のため現在は headless に待機せず fail-closed(synthetic error result)になる。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ reasoning トークンは各ターンの後に破棄される。次ターンに
|
||||||
1. `previous_response_id` パラメータで過去のレスポンスを参照
|
1. `previous_response_id` パラメータで過去のレスポンスを参照
|
||||||
2. `response.output` の全アイテムを次の `input` に手動で渡す
|
2. `response.output` の全アイテムを次の `input` に手動で渡す
|
||||||
|
|
||||||
ステートレス利用(`store=false`、ZDR組織)の場合は `include=["reasoning.encrypted_content"]` を指定すれば暗号化された推論コンテンツを受け取り、次リクエストに渡すことで推論を引き継げる。
|
ステートレス利用(`store=false`、ZDR組織)の場合は `include=["reasoning.encrypted_content"]` を指定すれば暗号化された推論コンテンツを受け取り、次リクエストに渡すことで推論を引き継げる。Insomnia は履歴から復元した reasoning item を通常の API message として扱い、独自の turn-boundary filtering はしない。
|
||||||
|
|
||||||
|
同一ターン内の function-call loop でも、`reasoning item → function_call → function_call_output → 次の Responses request` の連続性を保つため、履歴上の reasoning item は通常の API message として保持する。ToolResult は wire 上で user 側 item に見えるが、reasoning item の削除境界としては扱わない。
|
||||||
|
|
||||||
#### モデル世代差
|
#### モデル世代差
|
||||||
|
|
||||||
|
|
@ -185,7 +187,7 @@ Ollamaはローカル実行プラットフォームで、モデルごとに思
|
||||||
|
|
||||||
**ChatGPT を使うとき**
|
**ChatGPT を使うとき**
|
||||||
- 新規実装は **Responses API** を選ぶ(Chat Completions は推論引き継ぎが弱い)
|
- 新規実装は **Responses API** を選ぶ(Chat Completions は推論引き継ぎが弱い)
|
||||||
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる
|
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる。履歴上の reasoning item は通常の API message として扱い、独自の turn-boundary filtering はしない
|
||||||
- raw reasoning の抽出を試みない(規約違反の可能性)
|
- raw reasoning の抽出を試みない(規約違反の可能性)
|
||||||
|
|
||||||
**Ollama を使うとき**
|
**Ollama を使うとき**
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ provider = "codex-oauth"
|
||||||
context_window = 400000
|
context_window = 400000
|
||||||
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
|
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
|
||||||
|
|
||||||
|
[[model]]
|
||||||
|
id = "gpt-5.5"
|
||||||
|
provider = "codex-oauth"
|
||||||
|
context_window = 1000000
|
||||||
|
max_context_window = 272000
|
||||||
|
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
[[model]]
|
[[model]]
|
||||||
id = "anthropic/claude-sonnet-4"
|
id = "anthropic/claude-sonnet-4"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
id: 20260527-000022-manifest-profiles
|
id: 20260527-000022-manifest-profiles
|
||||||
slug: manifest-profiles
|
slug: manifest-profiles
|
||||||
title: 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
|
title: Nix profile entrypoints that resolve to portable Pod manifests
|
||||||
status: open
|
status: open
|
||||||
kind: task
|
kind: feature
|
||||||
priority: P2
|
priority: P2
|
||||||
labels: [migrated]
|
labels: [manifest, profiles, nix, tui]
|
||||||
created_at: 2026-05-27T00:00:22Z
|
created_at: 2026-05-27T00:00:22Z
|
||||||
updated_at: 2026-05-27T00:00:22Z
|
updated_at: 2026-05-29T15:55:00Z
|
||||||
assignee: null
|
assignee: null
|
||||||
legacy_ticket: null
|
legacy_ticket: null
|
||||||
---
|
---
|
||||||
|
|
@ -17,12 +17,100 @@ legacy_ticket: null
|
||||||
- legacy_ticket: null
|
- legacy_ticket: null
|
||||||
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
- migrated_from: TODO.md / tickets directory migration on 2026-05-27
|
||||||
|
|
||||||
# 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
|
# Nix profile entrypoints that resolve to portable Pod manifests
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
This work item was migrated from an unfinished TODO.md entry that did not have a dedicated legacy ticket file.
|
This work item was migrated from an unfinished TODO.md entry:
|
||||||
|
|
||||||
|
> 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
|
||||||
|
|
||||||
|
The current manifest cascade is good at configuration defaults by location: built-in defaults, user manifest, workspace manifest, and explicit overlays. That is less suitable for operational role selection. Users want to choose between profiles such as Orchestrator, Coder, Researcher, Reviewer, or cheap/fast variants, and they want those profiles to be portable as a pure artifact rather than assembled implicitly from several ambient layers.
|
||||||
|
|
||||||
|
Another problem is authoring ergonomics. The current manifest exposes many low-level numeric parameters that require implementation-specific intuition, such as compaction thresholds, pruning protection sizes, memory thresholds, and feature-specific token limits. Profiles should let users express high-level intent and reusable presets while the resolver produces the precise runtime manifest.
|
||||||
|
|
||||||
|
## Related work
|
||||||
|
|
||||||
|
- `work-items/open/20260529-145355-manifest-profile-encrypted-secrets/item.md`: profiles should integrate with explicit encrypted secret references so API keys/tokens are not limited to process environment variables.
|
||||||
|
|
||||||
|
## Design direction
|
||||||
|
|
||||||
|
Use Nix as the default human-authored profile format. A profile is a Nix expression that produces the final Pod manifest/configuration artifact through an Insomnia-provided `mkProfile` / `mkManifest` style library.
|
||||||
|
|
||||||
|
The profile itself is the source of truth. Commonality, imports, role presets, and any cascade-like behavior should be expressed in Nix by the profile author instead of being implemented as an additional ambient manifest cascade in Insomnia.
|
||||||
|
|
||||||
|
The runtime boundary should be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
selected Nix profile + explicit startup inputs
|
||||||
|
=> deterministic resolved manifest/config snapshot
|
||||||
|
=> Pod runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not introduce a three-layer authoring model where Nix generates TOML profiles that then merge into TOML manifests. That would make manifest/profile/Nix ownership unclear and hard to operate. Rust should consume the resolved artifact, ideally as a typed JSON/config representation, and preserve a snapshot for Pod restore.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Add a Nix-based profile entrypoint as the default path for new Pod creation.
|
||||||
|
- Provide an Insomnia Nix library with `mkProfile` / `mkManifest` helpers.
|
||||||
|
- The helper should produce a pure resolved manifest/config artifact that Rust can deserialize and validate.
|
||||||
|
- Profile authors may use Nix imports/functions to share common settings, implement their own cascade, or build role presets.
|
||||||
|
- Treat the resolved manifest/config as the runtime contract.
|
||||||
|
- Persist the selected profile identity/source and the resolved snapshot in Pod/session metadata.
|
||||||
|
- Pod resume should prefer the saved resolved snapshot, not silently re-evaluate the Nix profile.
|
||||||
|
- Re-evaluating a profile for an existing Pod must be explicit because it may change model, tools, permissions, or thresholds.
|
||||||
|
- Move role-oriented authoring into profiles.
|
||||||
|
- Support profiles for roles such as Orchestrator, Coder, Researcher, Reviewer, and cost/performance variants.
|
||||||
|
- Profiles should be able to select model/provider settings, prompts, tools, permissions, memory behavior, web/search behavior, workflows, skills, and context/compaction strategy.
|
||||||
|
- Prefer semantic presets in the Nix library for values that are difficult to tune by raw numbers, e.g. context budget, compaction behavior, retention, autonomy, and tool policy.
|
||||||
|
- Keep raw low-level numeric overrides available as an advanced escape hatch, not the primary user-facing interface.
|
||||||
|
- Shrink ambient cascade to discovery/default selection rather than runtime config merging.
|
||||||
|
- User/project configuration may provide profile registries, aliases, defaults, and UI preferences.
|
||||||
|
- User/project configuration should not be required as intermediate runtime override layers for model IDs, compaction thresholds, or other behavior controlled by the selected profile.
|
||||||
|
- Existing TOML manifest cascade can remain as compatibility/debug/test infrastructure, but it should not be the main profile design.
|
||||||
|
- Add profile discovery and selection UX.
|
||||||
|
- New Pod creation UI should show a selectable profile field such as `profile: coder (default)`.
|
||||||
|
- The profile picker should list built-in/user/project/explicit profiles with enough source/default information to avoid ambiguity.
|
||||||
|
- CLI/TUI should support explicit profile selection by name/source and by path/flakeref where appropriate.
|
||||||
|
- Ambiguous profile names should fail closed or require source-qualified selection rather than being implicitly merged.
|
||||||
|
- Keep secrets as references, not plaintext values.
|
||||||
|
- Nix profiles may refer to credentials using typed secret references, e.g. `secrets.ref "brave.search.default"`.
|
||||||
|
- Nix evaluation output, resolved config serialization, diagnostics, session logs, and model context must not contain plaintext secrets.
|
||||||
|
- Secret dereferencing/decryption happens in Rust at the consumer boundary.
|
||||||
|
- Define compatibility and fallback behavior.
|
||||||
|
- `--manifest` / TOML manifest loading may continue to work for compatibility, tests, fixtures, and low-level debugging.
|
||||||
|
- If Nix is unavailable, diagnostics should clearly say that profile resolution requires Nix and point to the manifest/resolved-config fallback path.
|
||||||
|
- Existing manifest behavior should not be broken until the Nix profile path is implemented and documented.
|
||||||
|
|
||||||
|
## Open design points
|
||||||
|
|
||||||
|
- Exact Nix entrypoint shape:
|
||||||
|
- flake output names, e.g. `insomniaProfiles.<name>` / `profiles.<name>`
|
||||||
|
- path-based profiles, e.g. `.insomnia/profiles/coder/profile.nix`
|
||||||
|
- whether both are supported initially
|
||||||
|
- Exact Rust-facing artifact:
|
||||||
|
- JSON resolved config vs TOML manifest snapshot vs a new typed `ResolvedPodConfig`
|
||||||
|
- whether `PodManifest` remains the final runtime type or becomes the legacy/compatibility representation
|
||||||
|
- Profile registry/default storage:
|
||||||
|
- where user-level profile aliases live
|
||||||
|
- where project-level defaults live
|
||||||
|
- how built-in profiles are exposed
|
||||||
|
- How much Nix support is external-command based initially vs embedded/library-integrated later.
|
||||||
|
- How profile summaries are generated for the new Pod UI without exposing low-level internals or secrets.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- Define the concrete requirements before implementation.
|
- A Nix profile can be selected when creating a new Pod and resolves to the complete runtime manifest/config for that Pod.
|
||||||
|
- Insomnia provides a documented `mkProfile` / `mkManifest` Nix helper for producing a valid resolved profile artifact.
|
||||||
|
- Profile authors can share common settings and implement cascade-like composition in Nix without relying on ambient user/project manifest merging.
|
||||||
|
- New Pod UI includes profile selection and displays the effective default, e.g. `profile: coder (default)`.
|
||||||
|
- CLI/TUI profile selection supports at least one explicit path/flakeref flow and one discovered-name/default flow.
|
||||||
|
- Resolved profile artifacts are validated with clear diagnostics before Pod creation.
|
||||||
|
- Pod/session metadata persists the selected profile identity/source and the resolved snapshot.
|
||||||
|
- Pod resume uses the persisted resolved snapshot unless the user explicitly asks to reload/re-resolve the profile.
|
||||||
|
- Secret references are preserved as references through Nix evaluation and resolved config; plaintext secrets are not written to config snapshots, logs, diagnostics, or model context.
|
||||||
|
- Existing TOML manifest path remains available as a compatibility/debug/test path during the migration.
|
||||||
|
- Documentation explains the new profile model, why ambient cascade is no longer the primary runtime config mechanism, and how users should structure reusable Nix profiles.
|
||||||
|
- Focused tests cover Nix profile resolution, validation errors, profile default/source selection, ambiguity handling, snapshot persistence, and no-plaintext secret serialization paths.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- Relevant manifest/profile/pod/tui tests pass.
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
---
|
|
||||||
id: 20260528-152959-web-search-fetch-tools
|
|
||||||
slug: web-search-fetch-tools
|
|
||||||
title: Add WebSearch and WebFetch tools
|
|
||||||
status: open
|
|
||||||
kind: task
|
|
||||||
priority: P2
|
|
||||||
labels: [tools, web, llm]
|
|
||||||
created_at: 2026-05-28T15:29:59Z
|
|
||||||
updated_at: 2026-05-28T15:29:59Z
|
|
||||||
assignee: null
|
|
||||||
legacy_ticket: null
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
Insomnia currently has strong local filesystem / shell / memory tools, but the agent cannot directly consult current web information except through user-provided excerpts or shell commands. Add first-class WebSearch and WebFetch tools so the model can gather public web information through bounded, observable tool calls.
|
|
||||||
|
|
||||||
This should be implemented as normal built-in tools, not as hidden context injection. Tool calls and results must remain visible in history, subject to manifest permission policy, and bounded by output limits.
|
|
||||||
|
|
||||||
## Requirement
|
|
||||||
|
|
||||||
- Add `WebSearch` tool.
|
|
||||||
- Input includes query string and optional result limit.
|
|
||||||
- Output returns structured results: title, URL, snippet/summary, source/search provider metadata where available.
|
|
||||||
- Search provider must be configurable. If no provider/API key is configured, the tool should fail with a clear diagnostic instead of falling back to scraping arbitrary search pages.
|
|
||||||
- Add `WebFetch` tool.
|
|
||||||
- Input includes URL and optional mode/limits.
|
|
||||||
- Output returns normalized text content plus metadata such as final URL, status, content type, title if available, and byte/token truncation indication.
|
|
||||||
- HTML should be converted to readable text. Non-text content should be rejected or summarized only when a safe explicit handler exists.
|
|
||||||
- Add manifest configuration for web tools.
|
|
||||||
- Enable/disable controls.
|
|
||||||
- Search provider/API key configuration.
|
|
||||||
- Fetch timeout, max response bytes, max output bytes/tokens, redirect limit.
|
|
||||||
- Allowed/denied URL schemes and host policy.
|
|
||||||
- Integrate with built-in tool registration and manifest permission policy.
|
|
||||||
- Web tools are normal tool calls and should go through the existing tool permission mechanism.
|
|
||||||
- No implicit network access should happen outside a tool call.
|
|
||||||
- Add security and reliability protections.
|
|
||||||
- Only `http`/`https` by default.
|
|
||||||
- Reject local/private/link-local/loopback addresses by default unless explicitly configured.
|
|
||||||
- Bound redirects and re-check final URLs.
|
|
||||||
- Bound download size and output size.
|
|
||||||
- Provide clear errors for timeout, DNS/network failure, unsupported content, blocked host/scheme, and truncation.
|
|
||||||
- Prompts/tool descriptions should tell the model when to use WebSearch vs WebFetch and that fetched content may be stale/untrusted.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- `WebSearch` and `WebFetch` are registered built-in tools when enabled/configured.
|
|
||||||
- Tool schemas are typed and validated.
|
|
||||||
- Manifest docs/config examples describe how to enable/configure web tools.
|
|
||||||
- Permission policy can allow/deny/ask these tools like other tools.
|
|
||||||
- Tool results are bounded and visible in history; no hidden web context is injected.
|
|
||||||
- Unit tests cover input validation, disabled/unconfigured errors, URL policy, redirect/final URL policy, output truncation, and representative HTML-to-text conversion.
|
|
||||||
- At least one integration-style test uses a local test HTTP server or mock provider rather than the public internet.
|
|
||||||
- `cargo fmt --check`
|
|
||||||
- `cargo check -p tools -p manifest -p pod`
|
|
||||||
- Relevant focused tests for tools/manifest.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Browser automation.
|
|
||||||
- Authenticated browsing / cookies / sessions.
|
|
||||||
- Javascript rendering.
|
|
||||||
- File downloads as attachments.
|
|
||||||
- Using arbitrary shell commands as the primary web access path.
|
|
||||||
- Hidden pre-request browsing or automatic web context injection.
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<!-- event: create author: tickets.sh at: 2026-05-28T15:29:59Z -->
|
|
||||||
|
|
||||||
## Created
|
|
||||||
|
|
||||||
Created by tickets.sh create.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
id: 20260529-145355-manifest-profile-encrypted-secrets
|
||||||
|
slug: manifest-profile-encrypted-secrets
|
||||||
|
title: Encrypted secret store for manifest profiles
|
||||||
|
status: open
|
||||||
|
kind: feature
|
||||||
|
priority: P2
|
||||||
|
labels: [manifest, profiles, secrets, security]
|
||||||
|
created_at: 2026-05-29T14:53:55Z
|
||||||
|
updated_at: 2026-05-29T14:53:55Z
|
||||||
|
assignee: null
|
||||||
|
legacy_ticket: null
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
WebSearch/WebFetch made API keys more visible as a UX problem: `WebSearch` currently expects `web.search.api_key_env`, so users must export `BRAVE_SEARCH_API_KEY` before starting the Pod/TUI process. That is inconvenient for long-lived Pods, profile switching, and per-project/provider configuration.
|
||||||
|
|
||||||
|
This should not be solved by adding `.env` loading as an implicit side effect. `.env` files are easy to leak into projects, do not solve profile-specific credential selection cleanly, and still expose secrets through process environments. Instead, when manifest profiles are designed/implemented, add a first-class encrypted secret store that manifests/profiles can reference.
|
||||||
|
|
||||||
|
Related work item: `work-items/open/20260527-000022-manifest-profiles/item.md`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Design a typed secret reference format for manifest/profile fields that need credentials.
|
||||||
|
- Existing env references such as `api_key_env = "BRAVE_SEARCH_API_KEY"` should keep working.
|
||||||
|
- Add a new encrypted-store reference form, e.g. `api_key_secret = "brave.search.default"` or a more general `SecretRef` enum.
|
||||||
|
- Secret references must be explicit in resolved config; do not silently read arbitrary `.env` files.
|
||||||
|
- Add an encrypted local secret store suitable for API keys/tokens.
|
||||||
|
- Store secrets outside tracked project files by default, under the user data/config directory.
|
||||||
|
- Use authenticated encryption and atomic writes.
|
||||||
|
- Do not log plaintext secrets, include them in session logs, expose them to model context, or return them through normal tool output.
|
||||||
|
- Keep encrypted blobs out of git-managed work-items/memory/session records.
|
||||||
|
- Integrate with manifest profiles.
|
||||||
|
- Profiles should be able to select different secret names for different roles/providers, e.g. Orchestrator/Coder/Researcher or web search provider variants.
|
||||||
|
- Profile resolution should validate that referenced secrets exist or produce a clear startup/tool diagnostic.
|
||||||
|
- A profile switch must not require restarting the shell just to change API keys.
|
||||||
|
- Provide a small CLI/TUI management surface.
|
||||||
|
- Add/update/list/delete secrets without printing plaintext by default.
|
||||||
|
- Support non-interactive set from stdin for scripts.
|
||||||
|
- Show references and metadata, not secret values.
|
||||||
|
- Consider migration helpers from existing env-var based configuration, but keep migration optional.
|
||||||
|
- Update credential consumers.
|
||||||
|
- WebSearch should support encrypted secret refs in addition to env vars.
|
||||||
|
- Provider API keys/tokens and future hosted/search credentials should be able to use the same mechanism.
|
||||||
|
- Existing env-var behavior remains as a fallback/compatibility path.
|
||||||
|
- Security and UX constraints.
|
||||||
|
- Fail closed when a referenced secret is missing or cannot be decrypted.
|
||||||
|
- Diagnostics should name the missing reference, not the secret value.
|
||||||
|
- Do not add hidden context injection or history mutation for secret resolution.
|
||||||
|
- Document the threat model and limitations, including OS account access and backup implications.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Manifest/profile schema has a typed credential reference that can point either to an env var or encrypted secret-store entry.
|
||||||
|
- Encrypted secret-store files are created outside the repository by default and use authenticated encryption with atomic update behavior.
|
||||||
|
- A user can add/list/delete a Brave Search API key in the secret store and configure `WebSearch` to use it without exporting an environment variable.
|
||||||
|
- Resolved configuration and diagnostics never display plaintext secrets.
|
||||||
|
- Missing/decryption-failed secrets produce clear fail-closed errors.
|
||||||
|
- Existing env-var based configuration continues to work.
|
||||||
|
- Documentation explains how profiles reference secrets and how to manage them.
|
||||||
|
- Focused tests cover config parsing/resolution, missing secret diagnostics, no-plaintext serialization/logging paths, and WebSearch secret resolution.
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- Relevant manifest/provider/tools/pod tests pass.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: tickets.sh at: 2026-05-29T14:53:55Z -->
|
||||||
|
|
||||||
|
## Created
|
||||||
|
|
||||||
|
Created by tickets.sh create.
|
||||||
|
|
||||||
|
---
|
||||||
Loading…
Reference in New Issue
Block a user