summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoraichat-agent <agent@aichat.local>2026-10-01 14:58:06 +0000
committeraichat-agent <agent@aichat.local>2026-10-01 15:54:19 +0000
commit83a2cd1ac24ca0feda34ae877aaf31f41b58b86c (patch)
treecbc83678fa05fe6a82908c448a4e20eae673a9c7
parent82976d349ad97ac9aae0655ad631dace5e2a6385 (diff)
downloadaichat-83a2cd1ac24ca0feda34ae877aaf31f41b58b86c.tar.gz
feat: add built-in subquery tool with hierarchical query ids
* Add config options subquery and subquery_max_depth. * Register an internal subquery function that spawns a fresh LLM query with the current model configuration but without the full context. The model selects context numerically (message_indexes) or by text excerpt (excerpts). * Subqueries can use tools, including further nested subqueries up to subquery_max_depth. * Queries and subqueries get hierarchical ids ([0], [0/1], [0/1/2]) that prefix tool-call prints and spinner messages.
-rw-r--r--config.example.yaml2
-rw-r--r--src/client/common.rs30
-rw-r--r--src/config/input.rs41
-rw-r--r--src/config/mod.rs22
-rw-r--r--src/config/subquery.rs360
-rw-r--r--src/function.rs29
-rw-r--r--src/main.rs4
-rw-r--r--src/render/stream.rs10
-rw-r--r--src/repl/mod.rs4
-rw-r--r--src/utils/abort_signal.rs68
10 files changed, 560 insertions, 10 deletions
diff --git a/config.example.yaml b/config.example.yaml
index 7c367d7..ae249b6 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -17,6 +17,8 @@ function_calling: true # Enables or disables function calling (Globall
mapping_tools: # Alias for a tool or toolset
fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write'
use_tools: null # Which tools to use by default. (e.g. 'fs,web_search')
+subquery: false # Enable the built-in subquery tool, letting the model spawn fresh context-limited LLM queries.
+subquery_max_depth: 3 # Maximum nesting depth of subqueries (the main query counts as depth 1).
# ---- prelude ----
repl_prelude: null # Set a default role or session for REPL mode (e.g. role:<name>, session:<name>, <session>:<role>)
diff --git a/src/client/common.rs b/src/client/common.rs
index 3b89d11..ed95291 100644
--- a/src/client/common.rs
+++ b/src/client/common.rs
@@ -403,6 +403,26 @@ pub async fn create_openai_compatible_client_config(
Ok(Some((model, clients)))
}
+async fn eval_tool_calls_with_ctx(
+ input: &Input,
+ client: &dyn Client,
+ tool_calls: Vec<ToolCall>,
+) -> Result<Vec<ToolResult>> {
+ let path = input.query_path();
+ let subquery_ctx = crate::config::SubqueryContext::from_input(input);
+ let mut ctx = crate::function::EvalToolCallsContext {
+ subquery_ctx: Some(subquery_ctx),
+ };
+ if path.is_empty() {
+ eval_tool_calls(client.global_config(), tool_calls, &mut ctx).await
+ } else {
+ crate::utils::with_query_scope(crate::utils::format_query_path(&path), async {
+ eval_tool_calls(client.global_config(), tool_calls, &mut ctx).await
+ })
+ .await
+ }
+}
+
pub async fn call_chat_completions(
input: &Input,
print: bool,
@@ -432,7 +452,10 @@ pub async fn call_chat_completions(
client.global_config().read().print_markdown(&text)?;
}
}
- Ok((text, eval_tool_calls(client.global_config(), tool_calls)?))
+ Ok((
+ text,
+ eval_tool_calls_with_ctx(input, client, tool_calls).await?,
+ ))
}
Err(err) => Err(err),
}
@@ -463,7 +486,10 @@ pub async fn call_chat_completions_streaming(
if !text.is_empty() && !text.ends_with('\n') {
println!();
}
- Ok((text, eval_tool_calls(client.global_config(), tool_calls)?))
+ Ok((
+ text,
+ eval_tool_calls_with_ctx(input, client, tool_calls).await?,
+ ))
}
Err(err) => {
if !text.is_empty() {
diff --git a/src/config/input.rs b/src/config/input.rs
index d6d154b..71807f3 100644
--- a/src/config/input.rs
+++ b/src/config/input.rs
@@ -27,6 +27,13 @@ pub struct Input {
medias: Vec<String>,
data_urls: HashMap<String, String>,
tool_calls: Option<MessageContentToolCalls>,
+ query_path: Vec<u64>,
+ subquery_data: Option<(
+ Vec<Message>,
+ Option<f64>,
+ Option<f64>,
+ Option<Vec<crate::function::FunctionDeclaration>>,
+ )>,
role: Role,
rag_name: Option<String>,
with_session: bool,
@@ -47,6 +54,8 @@ impl Input {
medias: Default::default(),
data_urls: Default::default(),
tool_calls: None,
+ query_path: vec![],
+ subquery_data: None,
role,
rag_name: None,
with_session,
@@ -114,6 +123,8 @@ impl Input {
medias,
data_urls,
tool_calls: Default::default(),
+ query_path: vec![],
+ subquery_data: None,
role,
rag_name: None,
with_session,
@@ -167,6 +178,25 @@ impl Input {
self.config.read().stream && !self.role().model().no_stream()
}
+ pub fn query_path(&self) -> Vec<u64> {
+ self.query_path.clone()
+ }
+
+ pub fn set_query_path(&mut self, path: Vec<u64>) {
+ self.query_path = path;
+ }
+
+ pub fn set_subquery(
+ &mut self,
+ messages: Vec<Message>,
+ temperature: Option<f64>,
+ top_p: Option<f64>,
+ functions: Option<Vec<crate::function::FunctionDeclaration>>,
+ ) {
+ self.subquery_data = Some((messages, temperature, top_p, functions));
+ self.with_session = false;
+ }
+
pub fn continue_output(&self) -> Option<&str> {
self.continue_output.as_deref()
}
@@ -235,6 +265,17 @@ impl Input {
model: &Model,
stream: bool,
) -> Result<ChatCompletionsData> {
+ if let Some((subquery_messages, subquery_temperature, subquery_top_p, subquery_functions)) =
+ &self.subquery_data
+ {
+ return Ok(crate::client::ChatCompletionsData {
+ messages: subquery_messages.clone(),
+ temperature: *subquery_temperature,
+ top_p: *subquery_top_p,
+ functions: subquery_functions.clone(),
+ stream,
+ });
+ }
let mut messages = self.build_messages()?;
patch_messages(&mut messages, model);
model.guard_max_input_tokens(&messages)?;
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 10718df..693c9f5 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -2,6 +2,7 @@ mod agent;
mod input;
mod role;
mod session;
+mod subquery;
pub use self::agent::{complete_agent_variables, list_agents, Agent, AgentVariables};
pub use self::input::Input;
@@ -9,6 +10,7 @@ pub use self::role::{
Role, RoleLike, CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, SHELL_ROLE,
};
use self::session::Session;
+pub use self::subquery::{eval_subquery, SubqueryContext};
use crate::client::{
create_client_config, list_client_types, list_models, ClientConfig, MessageContentToolCalls,
@@ -117,6 +119,9 @@ pub struct Config {
pub mapping_tools: IndexMap<String, String>,
pub use_tools: Option<String>,
+ pub subquery: bool,
+ pub subquery_max_depth: usize,
+
pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>,
pub agent_prelude: Option<String>,
@@ -193,6 +198,9 @@ impl Default for Config {
mapping_tools: Default::default(),
use_tools: None,
+ subquery: false,
+ subquery_max_depth: 3,
+
repl_prelude: None,
cmd_prelude: None,
agent_prelude: None,
@@ -599,6 +607,8 @@ impl Config {
("rag_top_k", rag_top_k.to_string()),
("dry_run", self.dry_run.to_string()),
("function_calling", self.function_calling.to_string()),
+ ("subquery", self.subquery.to_string()),
+ ("subquery_max_depth", self.subquery_max_depth.to_string()),
("stream", self.stream.to_string()),
("save", self.save.to_string()),
("keybindings", self.keybindings.clone()),
@@ -677,6 +687,14 @@ impl Config {
}
config.write().function_calling = value;
}
+ "subquery" => {
+ let value = value.parse().with_context(|| "Invalid value")?;
+ config.write().subquery = value;
+ }
+ "subquery_max_depth" => {
+ let value = value.parse().with_context(|| "Invalid value")?;
+ config.write().subquery_max_depth = value;
+ }
"stream" => {
let value = value.parse().with_context(|| "Invalid value")?;
config.write().stream = value;
@@ -1710,6 +1728,10 @@ impl Config {
);
functions = agent_functions;
}
+
+ if self.subquery {
+ functions.push(self::subquery::subquery_declaration());
+ }
};
if functions.is_empty() {
None
diff --git a/src/config/subquery.rs b/src/config/subquery.rs
new file mode 100644
index 0000000..6811791
--- /dev/null
+++ b/src/config/subquery.rs
@@ -0,0 +1,360 @@
+use super::{GlobalConfig, Input, RoleLike};
+
+use crate::client::{call_chat_completions, call_chat_completions_streaming, Message, MessageRole};
+use crate::function::ToolResult;
+use crate::utils::{
+ create_abort_signal, format_query_path, indent_text, next_query_id, with_query_scope,
+ AbortSignal,
+};
+
+use anyhow::{anyhow, bail, Result};
+use serde_json::{json, Value};
+
+pub const SUBQUERY_TOOL_NAME: &str = "subquery";
+
+pub fn subquery_declaration() -> crate::function::FunctionDeclaration {
+ crate::function::FunctionDeclaration {
+ name: SUBQUERY_TOOL_NAME.to_string(),
+ description: concat!(
+ "Delegate a fully self-contained sub task to a fresh LLM query that does NOT ",
+ "inherit the current conversation context. Only the context explicitly passed ",
+ "through the parameters (by message indexes and/or text excerpts) is visible to ",
+ "the subquery. The subquery itself can still use tools (including nested ",
+ "subqueries when the depth limit allows), and its final answer is returned to you."
+ )
+ .to_string(),
+ parameters: crate::function::JsonSchema {
+ type_value: Some("object".to_string()),
+ description: None,
+ properties: Some(
+ [
+ (
+ "task",
+ crate::function::JsonSchema {
+ type_value: Some("string".to_string()),
+ description: Some(
+ "The task or question for the subquery. Must be fully self-contained; do not rely on the surrounding conversation."
+ .to_string(),
+ ),
+ properties: None,
+ items: None,
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: None,
+ },
+ ),
+ (
+ "message_indexes",
+ crate::function::JsonSchema {
+ type_value: Some("array".to_string()),
+ description: Some(
+ "0-based indexes of the current context window messages to pass to the subquery as context. Messages are numbered in the order they appear in the context window, starting at 0."
+ .to_string(),
+ ),
+ properties: None,
+ items: Some(Box::new(crate::function::JsonSchema {
+ type_value: Some("integer".to_string()),
+ description: None,
+ properties: None,
+ items: None,
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: None,
+ })),
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: None,
+ },
+ ),
+ (
+ "excerpts",
+ crate::function::JsonSchema {
+ type_value: Some("array".to_string()),
+ description: Some(
+ "Verbatim text excerpts copied from the current context window, provided to the subquery as extra context."
+ .to_string(),
+ ),
+ properties: None,
+ items: Some(Box::new(crate::function::JsonSchema {
+ type_value: Some("string".to_string()),
+ description: None,
+ properties: None,
+ items: None,
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: None,
+ })),
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: None,
+ },
+ ),
+ ]
+ .into_iter()
+ .map(|(k, v)| (k.to_string(), v))
+ .collect(),
+ ),
+ items: None,
+ any_of: None,
+ enum_value: None,
+ default: None,
+ required: Some(vec!["task".to_string()]),
+ },
+ agent: false,
+ }
+}
+
+pub struct SubqueryContext {
+ path: Vec<u64>,
+ context_messages: Vec<Message>,
+}
+
+impl SubqueryContext {
+ pub fn from_input(input: &Input) -> Self {
+ let path = input.query_path();
+ let context_messages = if path.is_empty() {
+ vec![]
+ } else {
+ match input.build_messages() {
+ Ok(v) => v,
+ Err(err) => {
+ debug!("Failed to build context messages for subquery: {err}");
+ vec![]
+ }
+ }
+ };
+ Self {
+ path,
+ context_messages,
+ }
+ }
+}
+
+pub async fn eval_subquery(
+ config: &GlobalConfig,
+ ctx: &SubqueryContext,
+ arguments: Value,
+) -> Result<Value> {
+ let parent_path = ctx.path.clone();
+ if parent_path.is_empty() {
+ bail!("Invalid subquery call: missing parent query id");
+ }
+ let max_depth = config.read().subquery_max_depth;
+ if parent_path.len() >= max_depth {
+ bail!("Subquery maximum depth ({max_depth}) exceeded");
+ }
+ let (task, indexes, excerpts) = parse_subquery_args(&arguments)?;
+ let abort_signal = create_abort_signal();
+ let sub_id = next_query_id(&parent_path);
+ let mut sub_path = parent_path;
+ sub_path.push(sub_id);
+ let result = with_query_scope(format_query_path(&sub_path), async {
+ run_subquery_inner(config, ctx, task, indexes, excerpts, sub_path, abort_signal).await
+ })
+ .await;
+ match result {
+ Ok(text) => Ok(json!({ "output": text })),
+ Err(err) => Ok(json!({ "error": format!("{err:?}") })),
+ }
+}
+
+async fn run_subquery_inner(
+ config: &GlobalConfig,
+ ctx: &SubqueryContext,
+ task: String,
+ indexes: Vec<usize>,
+ excerpts: Vec<String>,
+ sub_path: Vec<u64>,
+ abort_signal: AbortSignal,
+) -> Result<String> {
+ let mut context_parts = vec![];
+ if !indexes.is_empty() {
+ context_parts.push(format!(
+ "The following messages were numerically selected from the parent context window:\n\n{}",
+ numbered_context_text(&ctx.context_messages, &indexes)
+ ));
+ }
+ if !excerpts.is_empty() {
+ context_parts.push(format!(
+ "The following text excerpts were selected from the parent context window:\n\n{}",
+ excerpts.join("\n\n")
+ ));
+ }
+ let mut messages = vec![];
+ if !context_parts.is_empty() {
+ messages.push(Message::new(
+ MessageRole::System,
+ crate::client::MessageContent::Text(context_parts.join("\n\n")),
+ ));
+ }
+ messages.push(Message::new(
+ MessageRole::User,
+ crate::client::MessageContent::Text(task),
+ ));
+ run_subquery(config, messages, sub_path, abort_signal).await
+}
+
+fn parse_subquery_args(arguments: &Value) -> Result<(String, Vec<usize>, Vec<String>)> {
+ let arguments = match arguments {
+ Value::Object(map) => Value::Object(map.clone()),
+ Value::String(text) => serde_json::from_str(text)
+ .map_err(|_| anyhow!("Invalid arguments for '{SUBQUERY_TOOL_NAME}': {text}"))?,
+ _ => bail!("Invalid arguments for '{SUBQUERY_TOOL_NAME}': {arguments}"),
+ };
+ let task = arguments["task"]
+ .as_str()
+ .map(|v| v.trim().to_string())
+ .unwrap_or_default();
+ if task.is_empty() {
+ bail!("The call '{SUBQUERY_TOOL_NAME}' requires a non-empty 'task' argument");
+ }
+ let indexes = match &arguments["message_indexes"] {
+ Value::Array(items) => items
+ .iter()
+ .filter_map(|v| match v {
+ Value::String(s) => s.trim().parse::<usize>().ok(),
+ _ => v.as_u64().map(|v| v as usize),
+ })
+ .collect(),
+ Value::Null => vec![],
+ other => {
+ bail!("The call '{SUBQUERY_TOOL_NAME}' has invalid 'message_indexes': {other}")
+ }
+ };
+ let excerpts = match &arguments["excerpts"] {
+ Value::Array(items) => items
+ .iter()
+ .filter_map(|v| v.as_str().map(|v| v.to_string()))
+ .collect(),
+ Value::String(v) => vec![v.clone()],
+ Value::Null => vec![],
+ other => bail!("The call '{SUBQUERY_TOOL_NAME}' has invalid 'excerpts': {other}"),
+ };
+ Ok((task, indexes, excerpts))
+}
+
+fn numbered_context_text(messages: &[Message], indexes: &[usize]) -> String {
+ let mut lines = vec![];
+ for (i, message) in messages.iter().enumerate() {
+ if !indexes.contains(&i) {
+ continue;
+ }
+ lines.push(format!(
+ "# {i} {}:\n{}",
+ format!("{:?}", message.role).to_lowercase(),
+ indent_text(message.content.to_text(), 4).trim_start()
+ ));
+ }
+ if lines.is_empty() {
+ "(no matching messages found)".into()
+ } else {
+ lines.join("\n\n")
+ }
+}
+
+async fn run_subquery(
+ config: &GlobalConfig,
+ mut messages: Vec<Message>,
+ path: Vec<u64>,
+ abort_signal: AbortSignal,
+) -> Result<String> {
+ let client = crate::client::init_client(config, None)?;
+ let (temperature, top_p, functions, stream) = {
+ let temp_input = Input::from_str(config, "", None);
+ let (temperature, top_p) = (temp_input.role().temperature(), temp_input.role().top_p());
+ let max_depth = config.read().subquery_max_depth;
+ let functions =
+ config
+ .read()
+ .select_functions(temp_input.role())
+ .map(|mut declarations| {
+ if path.len() < max_depth {
+ declarations.push(subquery_declaration());
+ }
+ declarations
+ });
+ (
+ temperature,
+ top_p,
+ functions,
+ config.read().stream && !temp_input.role().model().no_stream(),
+ )
+ };
+ let output;
+ let mut steps = 0;
+ loop {
+ steps += 1;
+ if steps > 8 {
+ bail!("Subquery exceeded the maximum number of tool-call steps");
+ }
+ let input = build_subquery_input(
+ config,
+ messages.clone(),
+ temperature,
+ top_p,
+ functions.clone(),
+ path.clone(),
+ )?;
+ let (text, tool_results) = if stream {
+ call_chat_completions_streaming(&input, client.as_ref(), abort_signal.clone()).await?
+ } else {
+ call_chat_completions(&input, false, false, client.as_ref(), abort_signal.clone())
+ .await?
+ };
+ if tool_results.is_empty() {
+ output = text;
+ break;
+ }
+ messages.extend(tool_result_messages(&text, &tool_results));
+ }
+ Ok(output)
+}
+
+fn build_subquery_input(
+ config: &GlobalConfig,
+ messages: Vec<Message>,
+ temperature: Option<f64>,
+ top_p: Option<f64>,
+ functions: Option<Vec<crate::function::FunctionDeclaration>>,
+ path: Vec<u64>,
+) -> Result<Input> {
+ let mut input = Input::from_str(config, "", None);
+ input.set_subquery(messages, temperature, top_p, functions);
+ input.set_query_path(path);
+ Ok(input)
+}
+
+fn tool_result_messages(text: &str, tool_results: &[ToolResult]) -> Vec<Message> {
+ let mut messages = vec![];
+ let text = text.trim();
+ if !text.is_empty() {
+ messages.push(Message::new(
+ MessageRole::Assistant,
+ crate::client::MessageContent::Text(text.to_string()),
+ ));
+ }
+ let results: Vec<ToolResult> = tool_results
+ .iter()
+ .cloned()
+ .enumerate()
+ .map(|(i, mut result)| {
+ if result.call.id.is_none() {
+ result.call.id = Some(format!("subquery-call-{}", i + 1));
+ }
+ result
+ })
+ .collect();
+ messages.push(Message::new(
+ MessageRole::Assistant,
+ crate::client::MessageContent::ToolCalls(crate::client::MessageContentToolCalls::new(
+ results,
+ String::new(),
+ )),
+ ));
+ messages
+}
diff --git a/src/function.rs b/src/function.rs
index 5907b71..778330a 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -1,5 +1,5 @@
use crate::{
- config::{Agent, Config, GlobalConfig},
+ config::{eval_subquery, Agent, Config, GlobalConfig, SubqueryContext},
utils::*,
};
@@ -18,7 +18,15 @@ const PATH_SEP: &str = ";";
#[cfg(not(windows))]
const PATH_SEP: &str = ":";
-pub fn eval_tool_calls(config: &GlobalConfig, mut calls: Vec<ToolCall>) -> Result<Vec<ToolResult>> {
+pub struct EvalToolCallsContext {
+ pub subquery_ctx: Option<SubqueryContext>,
+}
+
+pub async fn eval_tool_calls(
+ config: &GlobalConfig,
+ mut calls: Vec<ToolCall>,
+ ctx: &mut EvalToolCallsContext,
+) -> Result<Vec<ToolResult>> {
let mut output = vec![];
if calls.is_empty() {
return Ok(output);
@@ -29,7 +37,7 @@ pub fn eval_tool_calls(config: &GlobalConfig, mut calls: Vec<ToolCall>) -> Resul
}
let mut is_all_null = true;
for call in calls {
- let mut result = call.eval(config)?;
+ let mut result = call.eval(config, ctx).await?;
if result.is_null() {
result = json!("DONE");
} else {
@@ -170,7 +178,14 @@ impl ToolCall {
}
}
- pub fn eval(&self, config: &GlobalConfig) -> Result<Value> {
+ #[async_recursion::async_recursion]
+ pub async fn eval(&self, config: &GlobalConfig, ctx: &EvalToolCallsContext) -> Result<Value> {
+ if self.name == SUBQUERY_TOOL_NAME {
+ return match &ctx.subquery_ctx {
+ Some(sub_ctx) => eval_subquery(config, sub_ctx, self.arguments.clone()).await,
+ None => bail!("Invalid subquery call: missing parent query context"),
+ };
+ }
let (call_name, cmd_name, mut cmd_args, envs) = match &config.read().agent {
Some(agent) => self.extract_call_config_from_agent(config, agent)?,
None => self.extract_call_config_from_config(config)?,
@@ -245,6 +260,8 @@ impl ToolCall {
}
}
+const SUBQUERY_TOOL_NAME: &str = "subquery";
+
pub fn run_llm_function(
cmd_name: String,
cmd_args: Vec<String>,
@@ -274,6 +291,10 @@ pub fn run_llm_function(
#[cfg(windows)]
let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);
if *IS_STDOUT_TERMINAL {
+ let prompt = match current_query_scope() {
+ Some(scope) => format!("{scope} {prompt}"),
+ None => prompt,
+ };
println!("{}", dimmed_text(&prompt));
}
let exit_code = run_command(&cmd_name, &cmd_args, Some(envs))
diff --git a/src/main.rs b/src/main.rs
index 369a282..b30d022 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -195,10 +195,12 @@ async fn run(config: GlobalConfig, cli: Cli, text: Option<String>) -> Result<()>
#[async_recursion::async_recursion]
async fn start_directive(
config: &GlobalConfig,
- input: Input,
+ mut input: Input,
code_mode: bool,
abort_signal: AbortSignal,
) -> Result<()> {
+ let root_id = crate::utils::next_query_id(&[]);
+ input.set_query_path(vec![root_id]);
let client = input.create_client()?;
let extract_code = !*IS_STDOUT_TERMINAL && code_mode;
config.write().before_chat_completion(&input)?;
diff --git a/src/render/stream.rs b/src/render/stream.rs
index d6cadde..af69176 100644
--- a/src/render/stream.rs
+++ b/src/render/stream.rs
@@ -36,7 +36,10 @@ pub async fn raw_stream(
mut rx: UnboundedReceiver<SseEvent>,
abort_signal: &AbortSignal,
) -> Result<()> {
- let mut spinner = Some(spawn_spinner("Generating"));
+ let mut spinner = Some(spawn_spinner(&match crate::utils::current_query_scope() {
+ Some(scope) => format!("{scope} Generating"),
+ None => "Generating".to_string(),
+ }));
loop {
if abort_signal.aborted() {
@@ -75,7 +78,10 @@ async fn markdown_stream_inner(
let columns = terminal::size()?.0;
- let mut spinner = Some(spawn_spinner("Generating"));
+ let mut spinner = Some(spawn_spinner(&match crate::utils::current_query_scope() {
+ Some(scope) => format!("{scope} Generating"),
+ None => "Generating".to_string(),
+ }));
'outer: loop {
if abort_signal.aborted() {
diff --git a/src/repl/mod.rs b/src/repl/mod.rs
index f8a51d1..85f4f9c 100644
--- a/src/repl/mod.rs
+++ b/src/repl/mod.rs
@@ -723,6 +723,10 @@ async fn ask(
mut input: Input,
with_embeddings: bool,
) -> Result<()> {
+ if input.query_path().is_empty() {
+ let root_id = crate::utils::next_query_id(&[]);
+ input.set_query_path(vec![root_id]);
+ }
if input.is_empty() {
return Ok(());
}
diff --git a/src/utils/abort_signal.rs b/src/utils/abort_signal.rs
index a13e5bb..47534ae 100644
--- a/src/utils/abort_signal.rs
+++ b/src/utils/abort_signal.rs
@@ -2,7 +2,7 @@ use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use std::{
sync::{
- atomic::{AtomicBool, Ordering},
+ atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
},
time::Duration,
@@ -10,6 +10,72 @@ use std::{
pub type AbortSignal = Arc<AbortSignalInner>;
+#[derive(Debug, Default)]
+pub struct QueryIdGen {
+ counter: AtomicU64,
+ parents: parking_lot::RwLock<std::collections::HashMap<String, u64>>,
+}
+
+impl QueryIdGen {
+ pub fn root_id(&self) -> u64 {
+ self.counter.fetch_add(1, Ordering::SeqCst)
+ }
+
+ pub fn child_id(&self, parent_path: &[u64]) -> u64 {
+ if parent_path.is_empty() {
+ return self.root_id();
+ }
+ let key = parent_path
+ .iter()
+ .map(|v| v.to_string())
+ .collect::<Vec<_>>()
+ .join("/");
+ let mut parents = self.parents.write();
+ let entry = parents.entry(key).or_insert(0);
+ let id = *entry;
+ *entry += 1;
+ id
+ }
+}
+
+pub fn format_query_path(path: &[u64]) -> String {
+ if path.is_empty() {
+ return String::new();
+ }
+ format!(
+ "[{}]",
+ path.iter()
+ .map(|v| v.to_string())
+ .collect::<Vec<_>>()
+ .join("/")
+ )
+}
+
+tokio::task_local! {
+ static QUERY_SCOPE: String;
+ static QUERY_ID_GEN: QueryIdGen;
+}
+
+pub fn current_query_scope() -> Option<String> {
+ QUERY_SCOPE
+ .try_with(|v| if v.is_empty() { None } else { Some(v.clone()) })
+ .ok()
+ .flatten()
+}
+
+pub async fn with_query_scope<F: std::future::Future>(scope: String, future: F) -> F::Output {
+ QUERY_SCOPE.scope(scope, future).await
+}
+
+pub fn next_query_id(parent_path: &[u64]) -> u64 {
+ if parent_path.is_empty() {
+ return QUERY_ID_GEN.try_with(|g| g.root_id()).unwrap_or(0);
+ }
+ QUERY_ID_GEN
+ .try_with(|g| g.child_id(parent_path))
+ .unwrap_or(0)
+}
+
pub struct AbortSignalInner {
ctrlc: AtomicBool,
ctrld: AtomicBool,