Compare commits

...

32 Commits

Author SHA1 Message Date
c113e55ee2
Update .envrc 2026-05-30 00:40:12 +09:00
40a7185255
ticket: define nix manifest profiles 2026-05-30 00:39:50 +09:00
be0658380e
ticket: add encrypted secrets scaffolding 2026-05-30 00:08:58 +09:00
9a423d8a7a
ticket: add manifest encrypted secrets 2026-05-29 23:54:50 +09:00
56a5e47d63
ticket: close web search fetch tools 2026-05-29 18:24:31 +09:00
fed461036a
merge: web search fetch tools 2026-05-29 18:23:33 +09:00
68011f6628
ticket: review web search fetch tools 2026-05-29 18:23:33 +09:00
82dcc57475
fix: bound web search network reads 2026-05-29 18:21:17 +09:00
d9d36cabf4
ticket: close composer input history 2026-05-29 18:20:22 +09:00
d98f2ff7df
merge: composer input history 2026-05-29 18:20:06 +09:00
63dc466e63
ticket: review composer input history 2026-05-29 18:20:06 +09:00
95e08956eb
ticket: close multi-pod polling 2026-05-29 18:17:35 +09:00
d42b4c22e1
tui: add composer input history recall 2026-05-29 18:17:26 +09:00
dc986dc8e5
merge: multi-pod polling 2026-05-29 18:17:17 +09:00
e4a4bf62f1
ticket: review multi-pod polling 2026-05-29 18:17:17 +09:00
b391cb88e4
tui: poll multi pod dashboard 2026-05-29 17:58:46 +09:00
2be3a5bd36
feat: add web search and fetch tools 2026-05-29 17:58:11 +09:00
4c3ba12b43
ticket: add tui follow-up scaffolding 2026-05-29 17:51:06 +09:00
2b37ccb9a5
ticket: add composer input history recall 2026-05-29 17:46:32 +09:00
a3d8a5465e
ticket: add multi-pod polling 2026-05-29 17:39:22 +09:00
7a6d9c33f3
ticket: detail web tool implementation plan 2026-05-29 17:36:12 +09:00
37ead9c1a8
ticket: add Brave Search API notes 2026-05-29 17:31:33 +09:00
51e5354afe
ticket: close responses reasoning context safety 2026-05-29 17:11:45 +09:00
b9891e6127
merge: responses reasoning context safety 2026-05-29 17:10:22 +09:00
20ac7405e4
ticket: review responses reasoning context safety 2026-05-29 17:10:14 +09:00
504a928b82
tui: reduce task reminder frequency 2026-05-29 17:08:55 +09:00
27b1891f1c
fix: omit responses reasoning context 2026-05-29 17:07:28 +09:00
8ed5939ebb
fix: preserve responses reasoning history 2026-05-29 16:54:11 +09:00
b870a77a55
fix: guard responses reasoning context 2026-05-29 16:11:37 +09:00
8f3c935f52
ticket: detail responses context safety implementation 2026-05-29 15:53:39 +09:00
4c2b796345
ticket: note codex context window clamp 2026-05-29 15:45:36 +09:00
b14a141341
ticket: add responses reasoning context safety 2026-05-29 15:13:44 +09:00
33 changed files with 2367 additions and 115 deletions

4
.envrc
View File

@ -1 +1,5 @@
use flake
if [ -f .env ]; then
dotenv .env
fi

1
Cargo.lock generated
View File

@ -3503,6 +3503,7 @@ dependencies = [
"ignore",
"llm-worker",
"manifest",
"reqwest",
"schemars",
"serde",
"serde_json",

View File

@ -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]
fn reasoning_summary_field_is_always_serialized() {
// Responses API は reasoning item に `summary` を必須で要求する。
@ -509,6 +551,11 @@ mod tests {
let reasoning = body.reasoning.expect("reasoning should be set");
assert_eq!(reasoning.effort.as_deref(), Some("high"));
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]

View File

@ -14,7 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt};
use reqwest::header::{
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::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(
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
timeout: Duration,
@ -296,7 +345,11 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
let text = resp.text().await.unwrap_or_default();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
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
.get("message")
.and_then(|v| v.as_str())
@ -406,12 +459,14 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
let body = self
.scheme
.build_request_body(&self.model_id, &request, &self.capability);
let body_shape = request_body_shape_payload(&body);
emit_transport_trace(
&request,
"transport_body_build_done",
json!({
"elapsed_ms": body_started.elapsed().as_millis() as u64,
"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(),
"raw_json_bytes": request_body.raw_json_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() {
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(
&request,
"transport_http_status_error",
json!({
"status": response.status().as_u16(),
"retry_after_present": response.headers().get(RETRY_AFTER).is_some(),
"status": status,
"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(
@ -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]
async fn response_timeout_returns_retryable_lifecycle_timeout() {
let err = response_with_timeout(

View File

@ -2029,12 +2029,31 @@ fn items_trace_payload(
_ => 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!({
"items_len": items.len(),
"items_json_bytes": serde_json::to_vec(items).map(|bytes| bytes.len()).ok(),
"tools_len": tools_len,
"cache_anchor": cache_anchor,
"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_json_bytes": last.and_then(|item| serde_json::to_vec(item).ok().map(|bytes| bytes.len())),
"last_tool_result": last_tool_result,

View File

@ -18,7 +18,7 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{
CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig,
SessionConfig, SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
WorkerManifest,
WebConfig, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@ -46,6 +46,9 @@ pub struct PodManifestConfig {
pub permissions: Option<PermissionConfigPartial>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
#[serde(default)]
pub web: Option<WebConfig>,
/// Memory subsystem opt-in. See [`MemoryConfig`].
#[serde(default)]
pub memory: Option<MemoryConfig>,
@ -296,6 +299,7 @@ impl PodManifestConfig {
upper.compaction,
CompactionConfigPartial::merge,
),
web: merge_option(self.web, upper.web, WebConfig::merge),
memory: merge_option(self.memory, upper.memory, MemoryConfig::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 {
fn merge(self, upper: Self) -> Self {
Self {
@ -625,6 +673,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
session,
permissions,
compaction,
web: cfg.web,
memory: cfg.memory,
skills: cfg.skills,
})
@ -671,6 +720,7 @@ mod tests {
permissions: None,
session: None,
compaction: None,
web: None,
memory: None,
skills: None,
}
@ -1036,6 +1086,14 @@ mod tests {
prune_protected_tokens: Some(5_000),
..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()
};
let upper = PodManifestConfig {
@ -1043,6 +1101,13 @@ mod tests {
threshold: Some(80_000),
..Default::default()
}),
web: Some(WebConfig {
search: Some(crate::WebSearchConfig {
timeout_secs: Some(3),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let merged = lower.merge(upper);
@ -1050,6 +1115,9 @@ mod tests {
assert_eq!(c.threshold, Some(80_000));
// field from lower retained when upper has None
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]

View File

@ -53,6 +53,11 @@ pub struct PodManifest {
/// memory tools registered.
#[serde(default)]
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
/// Workflows. Each entry is a path to a skills *root* (i.e. a
/// directory whose children are individual `<name>/SKILL.md` skill
@ -79,6 +84,79 @@ pub struct SkillsConfig {
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; the workspace root defaults to the Pod's pwd unless an
/// explicit override is given.
@ -560,6 +638,26 @@ permission = "write"
assert!(manifest.worker.top_p.is_none());
assert!(manifest.worker.top_k.is_none());
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]

View File

@ -52,10 +52,16 @@ pub struct ModelManifest {
/// `default_capability` → scheme 既定の順で解決される。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<ModelCapability>,
/// モデルのコンテキストウィンドウ上限tokens。カタログ未掲載 / inline
/// モデルでもここで明示 override できる。
/// モデルの希望コンテキストウィンドウtokens。カタログ未掲載 / inline
/// モデルでもここで明示 override できる。実効値は `max_context_window`
/// またはカタログ上の backend maximum で clamp される。
#[serde(default, skip_serializing_if = "Option::is_none")]
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 {
@ -70,6 +76,7 @@ impl ModelManifest {
auth: upper.auth.or(self.auth),
capability: upper.capability.or(self.capability),
context_window: upper.context_window.or(self.context_window),
max_context_window: upper.max_context_window.or(self.max_context_window),
}
}
}

View File

@ -498,6 +498,7 @@ where
let session_id_for_usage = pod.segment_id().to_string();
let scope_change_sink = pod.scope_change_sink();
let memory_config = pod.manifest().memory.clone();
let web_config = pod.manifest().web.clone();
let spawner_name = pod.manifest().pod.name.clone();
let spawner_model = pod.manifest().model.clone();
let pod_store = pod.store().clone();
@ -521,6 +522,7 @@ where
tracker.clone(),
task_store,
bash_output_dir,
web_config,
));
// Memory subsystem opt-in. When `[memory]` is present in the

View File

@ -22,6 +22,7 @@ use tracing::info;
use tracing::warn;
use crate::compact::state::CompactState;
use crate::compact::usage_tracker::UsageTracker;
use session_store::{SystemItem, SystemReminder};
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`.
const FINAL_TEXT_PREVIEW_LIMIT: usize = 512;
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 8;
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 8;
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 24;
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 24;
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
#[derive(Debug)]
@ -91,6 +92,10 @@ pub(crate) struct PodInterceptor {
/// per-request `context` to estimate current occupancy for threshold
/// checks. `None` when compaction is disabled (both thresholds unset).
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`
/// via [`Self::pending_history_appends`] just before the next LLM
/// request. The Worker `extend`s these into its persistent history
@ -138,6 +143,7 @@ impl PodInterceptor {
registry,
compact_state,
usage_history,
usage_tracker: None,
pending_notifies,
pending_attachments,
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`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@ -175,7 +186,10 @@ impl PodInterceptor {
/// `usage_history` is not attached (compaction fully disabled).
fn estimated_tokens(&self, context: &[Item]) -> Option<u64> {
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)
}
@ -305,9 +319,15 @@ impl Interceptor for PodInterceptor {
if !state.is_disabled() && !state.just_compacted() {
let current = current_tokens.unwrap_or(0);
if state.exceeds_request(current) {
let shape = context_shape(context);
info!(
input_tokens = current,
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"
);
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> {
match item {
Item::Message { content, .. } => Some(
@ -528,6 +579,40 @@ mod tests {
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]
async fn pre_llm_request_runs_hooks_when_under_threshold() {
let count = Arc::new(AtomicUsize::new(0));

View File

@ -80,7 +80,7 @@ fn permission_ask_unsupported(input: &ToolCallSummary) -> ToolResult {
fn permission_target(arguments: &Value) -> String {
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) {
return value.to_string();
}

View File

@ -1270,7 +1270,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.task_reminder_state.clone(),
self.prompts.clone(),
self.log_writer.clone(),
);
)
.with_usage_tracker(self.usage_tracker.clone());
self.worker_mut().set_interceptor(interceptor);
self.interceptor_installed = true;
}

View File

@ -117,9 +117,14 @@ pub struct ModelEntry {
#[serde(default)]
pub capability: Option<ModelCapability>,
/// モデル単位の context window。省略時は provider default → builtin
/// fallback にフォールバックする。
/// fallback にフォールバックする。実効値は `max_context_window` で clamp
/// される。
#[serde(default)]
pub context_window: Option<u64>,
/// backend が実際に受け付ける context window の上限。UI や pre-request
/// safety は希望値ではなく clamp 済みの実効値を使う。
#[serde(default)]
pub max_context_window: Option<u64>,
}
/// 解決済みモデル設定。`build_client` が消費する完成形。
@ -130,7 +135,10 @@ pub struct ModelConfig {
pub model_id: String,
pub auth: AuthRef,
pub capability: Option<ModelCapability>,
/// Effective context window after backend maximum clamping.
pub context_window: u64,
/// Backend maximum that constrained `context_window`, when known.
pub max_context_window: Option<u64>,
}
#[derive(Debug, Deserialize)]
@ -259,7 +267,8 @@ fn split_ref(s: &str) -> Option<(&str, &str)> {
/// manifest 明示 > model catalog > provider.default_capability >
/// `build_client` 側で)`Scheme::default_capability()`。
/// 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> {
let providers = load_providers().map_err(ResolveError::LoadProviders)?;
let models = load_models().map_err(ResolveError::LoadModels)?;
@ -310,11 +319,15 @@ pub fn resolve_with_catalogs(
.and_then(|m| m.capability.clone())
.or_else(|| provider.default_capability.clone())
});
let context_window = manifest
let desired_context_window = manifest
.context_window
.or_else(|| model_entry.and_then(|m| m.context_window))
.or(provider.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 {
scheme,
base_url,
@ -322,6 +335,7 @@ pub fn resolve_with_catalogs(
auth,
capability,
context_window,
max_context_window,
})
} else {
let scheme = manifest
@ -335,17 +349,24 @@ pub fn resolve_with_catalogs(
.auth
.clone()
.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 {
scheme,
base_url: manifest.base_url.clone(),
model_id,
auth,
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)]
mod tests {
use super::*;
@ -420,6 +441,52 @@ mod tests {
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]
fn resolve_ref_with_inline_overrides() {
let providers = load_builtin_providers().unwrap();

View File

@ -187,6 +187,7 @@ mod tests {
},
capability: None,
context_window: 200_000,
max_context_window: None,
}
}
@ -315,6 +316,7 @@ mod tests {
auth: AuthRef::None,
capability: None,
context_window: 200_000,
max_context_window: None,
};
assert!(build_client_from_config(&config).is_ok());
}

View File

@ -13,6 +13,7 @@ grep-searcher = "0.1.16"
ignore = "0.4.25"
llm-worker = { workspace = true }
manifest = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@ -28,6 +28,7 @@ mod edit;
mod glob;
mod grep;
mod read;
mod web;
mod write;
pub use bash::bash_tool;
@ -39,6 +40,7 @@ pub use read::read_tool;
pub use scoped_fs::ScopedFs;
pub use task::{TaskEntry, TaskSnapshot, TaskStatus, TaskStore, task_tools};
pub use tracker::Tracker;
pub use web::{web_fetch_tool, web_search_tool};
pub use write::write_tool;
/// Register all builtin tools, wiring them to a shared `ScopedFs`
@ -57,6 +59,7 @@ pub fn builtin_tools(
tracker: Tracker,
task_store: TaskStore,
bash_output_dir: std::path::PathBuf,
web_config: Option<manifest::WebConfig>,
) -> Vec<llm_worker::tool::ToolDefinition> {
let mut defs = vec![
read_tool(fs.clone(), tracker.clone()),
@ -65,6 +68,8 @@ pub fn builtin_tools(
glob_tool(fs.clone()),
grep_tool(fs.clone()),
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

View File

@ -35,7 +35,7 @@
//! let tracker = Tracker::new(); // session lifetime
//! let bash_outputs = PathBuf::from("/run/insomnia/bash-output");
//! 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};

1082
crates/tools/src/web.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
tracker,
TaskStore::new(),
spill.path().to_path_buf(),
None,
));
(dir, spill, reg)
}

View File

@ -61,6 +61,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
tracker,
TaskStore::new(),
spill.path().to_path_buf(),
None,
));
(dir, spill, reg)
}
@ -94,6 +95,8 @@ fn builtin_tools_registers_full_set() {
"TaskGet",
"TaskList",
"TaskUpdate",
"WebFetch",
"WebSearch",
"Write"
]
);
@ -289,7 +292,7 @@ async fn edit_requires_read_across_tools() {
#[tokio::test]
async fn deterministic_tool_order_is_registration_order() {
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();
assert_eq!(
names,
@ -300,6 +303,8 @@ async fn deterministic_tool_order_is_registration_order() {
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
"TaskCreate",
"TaskList",
"TaskGet",
@ -319,6 +324,8 @@ fn tool_names_match_reference_spec() {
"Glob",
"Grep",
"Bash",
"WebSearch",
"WebFetch",
"TaskCreate",
"TaskList",
"TaskGet",
@ -344,6 +351,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
tracker.clone(),
TaskStore::new(),
spill.path().to_path_buf(),
None,
));
let a = dir.path().join("a.txt");

View File

@ -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)]
#[allow(dead_code)]
pub enum ActionbarNoticeLevel {
@ -205,6 +280,9 @@ pub struct App {
/// 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.
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
/// normally or reports that the empty assistant turn was rolled back.
pending_submit_rollback: Option<RollbackSubmitState>,
@ -251,6 +329,7 @@ impl App {
task_pane_open: false,
task_pane_scroll: 0,
queued_inputs: VecDeque::new(),
input_history: ComposerInputHistory::new(),
pending_submit_rollback: None,
last_rolled_back_input: None,
}
@ -365,6 +444,7 @@ impl App {
// `prefix_start` indexes the sigil atom; the text we want to
// replace lives just after it (sigil itself stays).
let typed_start = state.prefix_start + 1;
self.input_history.cancel_browse();
self.input.replace_with_text_at(typed_start, &text);
self.refresh_completion()
}
@ -419,6 +499,7 @@ impl App {
};
let kind = state.kind;
let start = state.prefix_start;
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
@ -453,6 +534,7 @@ impl App {
let kind = state.kind;
let start = state.prefix_start;
let value = entry.value.clone();
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_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
// is paused: resume the interrupted turn. Otherwise no-op.
if self.paused {
self.input_history.cancel_browse();
self.input.clear();
return Some(Method::Resume);
}
return None;
}
self.input_history.record(segments.clone());
if self.running {
self.queued_inputs.push_back(QueuedInput::new(segments));
self.input.clear();
@ -503,6 +587,44 @@ impl App {
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(
&mut self,
text: impl Into<String>,
@ -566,6 +688,7 @@ impl App {
let Some(queued) = self.queued_inputs.pop_front() else {
return false;
};
self.input_history.cancel_browse();
self.input.replace_with_segments(&queued.segments);
self.completion = None;
true
@ -1512,6 +1635,9 @@ impl App {
// keeping the normal composer buffer intact.
pub fn insert_char(&mut self, c: char) {
let command_mode = self.is_command_mode();
if !command_mode {
self.input_history.cancel_browse();
}
self.active_input_mut().insert_char(c);
if command_mode {
self.command_completion_selected = None;
@ -1519,6 +1645,9 @@ impl App {
}
pub fn insert_newline(&mut self) {
let command_mode = self.is_command_mode();
if !command_mode {
self.input_history.cancel_browse();
}
self.active_input_mut().insert_newline();
if command_mode {
self.command_completion_selected = None;
@ -1529,11 +1658,15 @@ impl App {
self.command_input.insert_str(&content);
self.command_completion_selected = None;
} else {
self.input_history.cancel_browse();
self.input.insert_paste(content);
}
}
pub fn delete_char_before(&mut self) {
let command_mode = self.is_command_mode();
if !command_mode {
self.input_history.cancel_browse();
}
self.active_input_mut().delete_before();
if command_mode {
self.command_completion_selected = None;
@ -1541,6 +1674,9 @@ impl App {
}
pub fn delete_char_after(&mut self) {
let command_mode = self.is_command_mode();
if !command_mode {
self.input_history.cancel_browse();
}
self.active_input_mut().delete_after();
if command_mode {
self.command_completion_selected = None;
@ -2781,6 +2917,124 @@ mod completion_flow_tests {
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]
fn task_pane_toggle_flips_state_and_resets_scroll() {
let mut app = App::new("test".into());

View File

@ -176,6 +176,14 @@ impl InputBuffer {
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
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input.

View File

@ -439,7 +439,7 @@ async fn run_multi() -> Result<(), Box<dyn std::error::Error>> {
return Err(error);
}
}
app.reload().await?;
app.reload_or_notice().await;
}
}
}
@ -972,13 +972,21 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.refresh_completion()
}
KeyCode::Up => {
if app.can_browse_input_history_older() && app.browse_input_history_older() {
app.refresh_completion()
} else {
app.move_cursor_up();
app.refresh_completion()
}
}
KeyCode::Down => {
if app.can_browse_input_history_newer() && app.browse_input_history_newer() {
app.refresh_completion()
} else {
app.move_cursor_down();
app.refresh_completion()
}
}
KeyCode::Home => {
app.move_cursor_home();
app.refresh_completion()
@ -1923,6 +1931,70 @@ mod tests {
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) {
assert!(handle_key(app, key(KeyCode::Char(':'))).is_none());
assert!(app.is_command_mode());

View File

@ -1,8 +1,8 @@
use std::io;
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::{ErrorCode, Event, InvokeKind, Method, PodStatus, Segment};
use ratatui::Frame;
@ -25,6 +25,8 @@ use crate::pod_list::{
const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 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)]
pub(crate) enum MultiPodError {
@ -83,8 +85,28 @@ pub(crate) async fn run(
return Err(MultiPodError::NoPods);
}
let mut pending_reload = PendingReload::default();
let mut next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
loop {
if let Some(result) = pending_reload.finish_if_ready().await {
app.apply_reload_result(result);
}
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()? {
TermEvent::Key(key) => match app.handle_key(key) {
MultiPodAction::None => {}
@ -94,12 +116,18 @@ pub(crate) async fn run(
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) => {
pending_reload.abort();
terminal.draw(|f| draw(f, app))?;
let result = send_run_and_confirm(&request.socket_path, request.segments).await;
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),
@ -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> {
manifest::paths::sessions_dir().ok_or_else(|| {
MultiPodError::Io(io::Error::new(
@ -151,10 +237,29 @@ impl MultiPodApp {
Ok(app)
}
pub(crate) async fn reload(&mut self) -> Result<(), MultiPodError> {
self.list = load_pod_list(self.list.selected_name.clone()).await?;
pub(crate) async fn reload_or_notice(&mut self) {
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();
Ok(())
}
#[cfg(test)]
@ -967,6 +1072,108 @@ mod tests {
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]
fn multi_idle_live_selected_target_is_send_eligible() {
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);

View File

@ -178,6 +178,20 @@ tool = "Write"
pattern = "*.env"
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]
prune_protected_tokens = 8000
prune_min_savings = 4096
@ -220,6 +234,30 @@ scheme 側が吸収する。
生成設定は provider 別の値域検証を行わない。型が TOML と合わない場合は manifest
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]` が無い場合、ツール permission 層は無効で従来通り実行する。`[permissions]` を書く場合は `default_action = "allow" | "deny" | "ask"` が必須で、`[[permissions.rule]]` は宣言順に最初に一致した rule が採用される。一致しなければ `default_action` を使う。
@ -234,7 +272,7 @@ pattern = "rm *"
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-closedsynthetic error resultになる。

View File

@ -85,7 +85,9 @@ reasoning トークンは各ターンの後に破棄される。次ターンに
1. `previous_response_id` パラメータで過去のレスポンスを参照
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 を使うとき**
- 新規実装は **Responses API** を選ぶChat Completions は推論引き継ぎが弱い)
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる
- ZDR組織でも `reasoning.encrypted_content` で推論を引き継げる。履歴上の reasoning item は通常の API message として扱い、独自の turn-boundary filtering はしない
- raw reasoning の抽出を試みない(規約違反の可能性)
**Ollama を使うとき**

View File

@ -38,6 +38,13 @@ provider = "codex-oauth"
context_window = 400000
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
[[model]]
id = "anthropic/claude-sonnet-4"

View File

@ -1,13 +1,13 @@
---
id: 20260527-000022-manifest-profiles
slug: manifest-profiles
title: 事前定義したManifestをProfile的に扱い、Orchestrator/Coder/Researcherで別々のモデル/設定を使わせる運用ができるようにする
title: Nix profile entrypoints that resolve to portable Pod manifests
status: open
kind: task
kind: feature
priority: P2
labels: [migrated]
labels: [manifest, profiles, nix, tui]
created_at: 2026-05-27T00:00:22Z
updated_at: 2026-05-27T00:00:22Z
updated_at: 2026-05-29T15:55:00Z
assignee: null
legacy_ticket: null
---
@ -17,12 +17,100 @@ legacy_ticket: null
- legacy_ticket: null
- 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
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
- 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.

View File

@ -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.

View File

@ -1,7 +0,0 @@
<!-- event: create author: tickets.sh at: 2026-05-28T15:29:59Z -->
## Created
Created by tickets.sh create.
---

View File

@ -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.

View File

@ -0,0 +1,7 @@
<!-- event: create author: tickets.sh at: 2026-05-29T14:53:55Z -->
## Created
Created by tickets.sh create.
---