Skip to content

Commit 3af66ce

Browse files
RoyLinRoyLin
authored andcommitted
feat: add btw (by the way) ephemeral side question feature
- Add btw() method to AgentSession for asking side questions without affecting conversation history - Implement BtwResult type with question, answer, and token usage - Add BtwAnswer event type for streaming support - Add independent btw_system.md prompt for simplified responses - Implement CommandAction::BtwQuery for command handling - Add Node.js SDK bindings with full TypeScript types - Add Python SDK bindings with PyBtwResult class - Add comprehensive test examples and documentation - Update API documentation (English and Chinese) - Bump version to 1.4.1 Key features: - Read-only history snapshot for concurrent safety - No tool execution (LLM knowledge + context only) - Never modifies conversation history - Safe to call concurrently with send()/stream() - Returns complete token usage statistics Examples: - crates/code/sdk/node/examples/test_btw_feature.ts - crates/code/sdk/node/examples/test_btw_simple.ts - crates/code/examples/BTW_TEST_GUIDE.md
1 parent a1a3630 commit 3af66ce

25 files changed

Lines changed: 887 additions & 353 deletions

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "1.4.0"
3+
version = "1.4.1"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/prompts/btw_system.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
You are answering a transient side question from the user.
2+
3+
The conversation history above provides your full context.
4+
Your answer will NOT be saved to the conversation — it is ephemeral.
5+
6+
Guidelines:
7+
- Answer concisely and directly based on the conversation context
8+
- Do not use any tools; answer only from what is already in context
9+
- If the answer cannot be determined from the context, say so clearly
10+
- Do not acknowledge that this is a "side question" or mention that it is ephemeral

core/src/agent.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,20 @@ pub enum AgentEvent {
493493
operation: String,
494494
error: String,
495495
},
496+
497+
// ========================================================================
498+
// Side question (btw)
499+
// ========================================================================
500+
/// Ephemeral side question answered.
501+
///
502+
/// Emitted by [`crate::agent_api::AgentSession::btw()`] in streaming mode.
503+
/// The answer is never added to conversation history.
504+
#[serde(rename = "btw_answer")]
505+
BtwAnswer {
506+
question: String,
507+
answer: String,
508+
usage: TokenUsage,
509+
},
496510
}
497511

498512
/// Result of agent execution

core/src/agent_api.rs

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
1919
use crate::agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
2020
use crate::commands::{
21-
CommandContext, CommandRegistry, CronCancelCommand, CronListCommand, LoopCommand,
21+
CommandAction, CommandContext, CommandRegistry, CronCancelCommand, CronListCommand, LoopCommand,
2222
};
2323
use crate::config::CodeConfig;
2424
use crate::error::{read_or_recover, write_or_recover, Result};
@@ -1333,6 +1333,24 @@ impl Agent {
13331333
}
13341334
}
13351335

1336+
// ============================================================================
1337+
// BtwResult
1338+
// ============================================================================
1339+
1340+
/// Result of a `/btw` ephemeral side question.
1341+
///
1342+
/// The answer is never added to conversation history.
1343+
/// Returned by [`AgentSession::btw()`].
1344+
#[derive(Debug, Clone)]
1345+
pub struct BtwResult {
1346+
/// The original question.
1347+
pub question: String,
1348+
/// The LLM's answer.
1349+
pub answer: String,
1350+
/// Token usage for this ephemeral call.
1351+
pub usage: crate::llm::TokenUsage,
1352+
}
1353+
13361354
// ============================================================================
13371355
// AgentSession
13381356
// ============================================================================
@@ -1509,7 +1527,21 @@ impl AgentSession {
15091527
// Slash command interception
15101528
if CommandRegistry::is_command(prompt) {
15111529
let ctx = self.build_command_context();
1512-
if let Some(output) = self.command_registry().dispatch(prompt, &ctx) {
1530+
let output = self.command_registry().dispatch(prompt, &ctx);
1531+
// Drop the MutexGuard before any async operations
1532+
if let Some(output) = output {
1533+
// BtwQuery requires an async LLM call — handle it here.
1534+
if let Some(CommandAction::BtwQuery(ref question)) = output.action {
1535+
let result = self.btw(question).await?;
1536+
return Ok(AgentResult {
1537+
text: result.answer,
1538+
messages: history
1539+
.map(|h| h.to_vec())
1540+
.unwrap_or_else(|| read_or_recover(&self.history).clone()),
1541+
tool_calls_count: 0,
1542+
usage: result.usage,
1543+
});
1544+
}
15131545
return Ok(AgentResult {
15141546
text: output.text,
15151547
messages: history
@@ -1590,6 +1622,53 @@ impl AgentSession {
15901622
Ok(result)
15911623
}
15921624

1625+
/// Ask an ephemeral side question without affecting conversation history.
1626+
///
1627+
/// Takes a read-only snapshot of the current history, makes a separate LLM
1628+
/// call with no tools, and returns the answer. History is never modified.
1629+
///
1630+
/// Safe to call concurrently with an ongoing [`send()`](Self::send) — the
1631+
/// snapshot only acquires a read lock on the internal history.
1632+
///
1633+
/// # Example
1634+
///
1635+
/// ```rust,no_run
1636+
/// # async fn run(session: &a3s_code_core::AgentSession) -> anyhow::Result<()> {
1637+
/// let result = session.btw("what file was that error in?").await?;
1638+
/// println!("{}", result.answer);
1639+
/// # Ok(())
1640+
/// # }
1641+
/// ```
1642+
pub async fn btw(&self, question: &str) -> Result<BtwResult> {
1643+
let question = question.trim();
1644+
if question.is_empty() {
1645+
return Err(crate::error::CodeError::Session(
1646+
"btw: question cannot be empty".to_string(),
1647+
));
1648+
}
1649+
1650+
// Snapshot current history — read-only, does not block send().
1651+
let history_snapshot = read_or_recover(&self.history).clone();
1652+
1653+
// Append the side question as a temporary user turn.
1654+
let mut messages = history_snapshot;
1655+
messages.push(Message::user(question));
1656+
1657+
let response = self
1658+
.llm_client
1659+
.complete(&messages, Some(crate::prompts::BTW_SYSTEM), &[])
1660+
.await
1661+
.map_err(|e| {
1662+
crate::error::CodeError::Llm(format!("btw: ephemeral LLM call failed: {e}"))
1663+
})?;
1664+
1665+
Ok(BtwResult {
1666+
question: question.to_string(),
1667+
answer: response.text(),
1668+
usage: response.usage,
1669+
})
1670+
}
1671+
15931672
/// Send a prompt with image attachments and wait for the complete response.
15941673
///
15951674
/// Images are included as multi-modal content blocks in the user message.
@@ -1673,8 +1752,51 @@ impl AgentSession {
16731752
// Slash command interception for streaming
16741753
if CommandRegistry::is_command(prompt) {
16751754
let ctx = self.build_command_context();
1676-
if let Some(output) = self.command_registry().dispatch(prompt, &ctx) {
1755+
let output = self.command_registry().dispatch(prompt, &ctx);
1756+
// Drop the MutexGuard before spawning async tasks
1757+
if let Some(output) = output {
16771758
let (tx, rx) = mpsc::channel(256);
1759+
1760+
// BtwQuery: make the ephemeral call and emit BtwAnswer event.
1761+
if let Some(CommandAction::BtwQuery(question)) = output.action {
1762+
// Snapshot history and clone the client before entering the task.
1763+
let llm_client = self.llm_client.clone();
1764+
let history_snapshot = read_or_recover(&self.history).clone();
1765+
let handle = tokio::spawn(async move {
1766+
let mut messages = history_snapshot;
1767+
messages.push(Message::user(&question));
1768+
match llm_client
1769+
.complete(&messages, Some(crate::prompts::BTW_SYSTEM), &[])
1770+
.await
1771+
{
1772+
Ok(response) => {
1773+
let answer = response.text();
1774+
let _ = tx
1775+
.send(AgentEvent::BtwAnswer {
1776+
question: question.clone(),
1777+
answer: answer.clone(),
1778+
usage: response.usage,
1779+
})
1780+
.await;
1781+
let _ = tx
1782+
.send(AgentEvent::End {
1783+
text: answer,
1784+
usage: crate::llm::TokenUsage::default(),
1785+
})
1786+
.await;
1787+
}
1788+
Err(e) => {
1789+
let _ = tx
1790+
.send(AgentEvent::Error {
1791+
message: format!("btw failed: {e}"),
1792+
})
1793+
.await;
1794+
}
1795+
}
1796+
});
1797+
return Ok((rx, handle));
1798+
}
1799+
16781800
let handle = tokio::spawn(async move {
16791801
let _ = tx
16801802
.send(AgentEvent::TextDelta {

core/src/commands.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ pub enum CommandAction {
8181
ClearHistory,
8282
/// Switch to a different model.
8383
SwitchModel(String),
84+
/// Ask an ephemeral side question (handled async by the session).
85+
///
86+
/// The session makes a separate LLM call with the current history snapshot
87+
/// and returns the answer without modifying conversation history.
88+
BtwQuery(String),
8489
}
8590

8691
impl CommandOutput {
@@ -134,6 +139,7 @@ impl CommandRegistry {
134139
commands: HashMap::new(),
135140
};
136141
registry.register(Arc::new(HelpCommand));
142+
registry.register(Arc::new(BtwCommand));
137143
registry.register(Arc::new(CompactCommand));
138144
registry.register(Arc::new(CostCommand));
139145
registry.register(Arc::new(ModelCommand));
@@ -227,6 +233,30 @@ impl Default for CommandRegistry {
227233

228234
// ─── Built-in Commands ──────────────────────────────────────────────
229235

236+
struct BtwCommand;
237+
238+
impl SlashCommand for BtwCommand {
239+
fn name(&self) -> &str {
240+
"btw"
241+
}
242+
fn description(&self) -> &str {
243+
"Ask a side question without affecting conversation history"
244+
}
245+
fn usage(&self) -> Option<&str> {
246+
Some("/btw <question>")
247+
}
248+
fn execute(&self, args: &str, _ctx: &CommandContext) -> CommandOutput {
249+
let question = args.trim();
250+
if question.is_empty() {
251+
return CommandOutput::text(
252+
"Usage: /btw <question>\nExample: /btw what file was that error in?",
253+
);
254+
}
255+
// The actual LLM call is async — signal the session to handle it.
256+
CommandOutput::with_action(String::new(), CommandAction::BtwQuery(question.to_string()))
257+
}
258+
}
259+
230260
struct HelpCommand;
231261

232262
impl SlashCommand for HelpCommand {

core/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ pub mod tools;
9191
// Re-export key types at crate root for ergonomic usage
9292
pub use a3s_lane::MetricsSnapshot;
9393
pub use agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
94-
pub use agent_api::{Agent, AgentSession, SessionOptions, ToolCallResult};
94+
pub use agent_api::{Agent, AgentSession, BtwResult, SessionOptions, ToolCallResult};
9595
pub use agent_teams::{
9696
AgentExecutor, AgentTeam, TeamConfig, TeamMember, TeamMemberOptions, TeamMessage, TeamRole,
9797
TeamRunResult, TeamRunner, TeamTaskBoard,

core/src/prompts.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ pub const TEAM_REVIEWER: &str = include_str!("../prompts/team_reviewer.md");
105105
/// Skill catalog header injected before listing available skill names/descriptions.
106106
pub const SKILLS_CATALOG_HEADER: &str = include_str!("../prompts/skills_catalog_header.md");
107107

108+
// ============================================================================
109+
// Side Question (btw)
110+
// ============================================================================
111+
112+
/// System prompt for `/btw` ephemeral side questions.
113+
///
114+
/// Used by [`crate::agent_api::AgentSession::btw()`] — the answer is never
115+
/// added to conversation history.
116+
pub const BTW_SYSTEM: &str = include_str!("../prompts/btw_system.md");
117+
108118
// ============================================================================
109119
// System Prompt Slots
110120
// ============================================================================
@@ -263,6 +273,7 @@ mod tests {
263273
assert!(!TEAM_LEAD.is_empty());
264274
assert!(!TEAM_REVIEWER.is_empty());
265275
assert!(!SKILLS_CATALOG_HEADER.is_empty());
276+
assert!(!BTW_SYSTEM.is_empty());
266277
assert!(!PLAN_EXECUTE_GOAL.is_empty());
267278
assert!(!PLAN_EXECUTE_STEP.is_empty());
268279
assert!(!PLAN_FALLBACK_STEP.is_empty());

0 commit comments

Comments
 (0)