Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/architecture/agent-runtime-services-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可
私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个
GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。

Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v3 preview:
Rust Runtime SDK 以 `AGENT_RUNTIME_SDK_API_VERSION` 标记兼容边界。当前接口版本为 v4 preview:
小版本更新允许增加可选 builder hook、有默认实现的端口方法或注册表查询能力,但不得向外部可用
Rust 结构体字面量(struct literal)构造的 DTO 直接增加字段,也不得改变既有端口语义、错误分类、session / turn 标识含义或
默认 feature 依赖。任何需要调用方改写现有嵌入代码的变更,必须提升接口版本并提供兼容迁移路径。
Expand All @@ -80,6 +80,9 @@ v2 的迁移只涉及 Rust 错误名词治理:调用方把
v3 为 `AgentDialogTurnRequest` 增加来源无关的 `execution` 事实。现有 Rust struct literal 调用方迁移时增加
`execution: AgentDialogTurnExecution::Standard`(或 `Default::default()`);旧 wire payload 缺省为标准执行。

v4 将活动 Turn 的文本 steer 纳入 `AgentDialogTurnPort`,复用同一个 Runtime owner 和精确 Session/Turn
身份校验;默认端口实现仍返回 `NotAvailable`,未选择该能力的 provider 不需要建立第二套 queue 或 transport。

只要外部调用方仍必须导入 `bitfun-core`、启用 `product-full`、持有具体服务管理器、读取产品命令
注册表、理解 ACP/内部端口或依赖全局可变状态,公开 SDK 发布边界就不成立。公开 SDK 的完整
术语、能力等价和版本要求以 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准。
Expand Down Expand Up @@ -423,7 +426,7 @@ impl AgentRuntime {
该 Rust 接口是内部产品入口复用的当前形态,不是公开 Python/TypeScript SDK 的目标 API。它必须只接收
已组装的类型化部件,不负责创建
文件系统、终端、MCP、AI 客户端、Remote 提供方或产品命令。
当前 v3 preview 接口以 message / attachment / metadata 和默认标准执行目标作为最小输入形态;若把
当前 v4 preview 接口以 message / attachment / metadata、默认标准执行目标和活动 Turn 文本 steer 作为最小输入形态;若把
model-round cancellation token、结构化 AgentInput 或更复杂的事件游标纳入公开 SDK,
必须分别评审 Rust Runtime SDK、SDK Host protocol 和公开 SDK API 的版本,并保留旧路径兼容。

Expand Down
2 changes: 1 addition & 1 deletion scripts/core-boundaries/rules/source/forbidden-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const forbiddenContentRules = [
reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice',
patterns: [
{
regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|WorkspaceDiff|SubmitTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/,
regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|WorkspaceDiff|SubmitTurn|SteerTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnSteered|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogSteerRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/,
message:
'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice',
},
Expand Down
5 changes: 4 additions & 1 deletion scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5086,10 +5086,13 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis
runtimeIpcOperationPattern.test(' WorkspaceDiff {') ||
runtimeIpcOperationPattern.test(' WorkspaceDiffSnapshot,') ||
runtimeIpcOperationPattern.test(' SubmitTurn {') ||
runtimeIpcOperationPattern.test(' SteerTurn {') ||
runtimeIpcOperationPattern.test(' AgentDialogSteerRequest {') ||
runtimeIpcOperationPattern.test(' RunUserShellCommand {') ||
runtimeIpcOperationPattern.test(' AgentUserShellCommandRequest {') ||
runtimeIpcOperationPattern.test(' SessionForked {') ||
runtimeIpcOperationPattern.test(' SessionReverted {')
runtimeIpcOperationPattern.test(' SessionReverted {') ||
runtimeIpcOperationPattern.test(' TurnSteered {')
) {
throw new Error('agent-runtime-ipc operation guard must preserve the Shared TUI operation budget');
}
Expand Down
95 changes: 83 additions & 12 deletions src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ use std::sync::{Arc, RwLock};
use tokio::sync::{broadcast, Mutex};

use bitfun_agent_runtime::sdk::{
AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, AgentInputAttachment,
AgentLocalCommandTurnRecordRequest, AgentMessageWorkspaceReferencesRequest, AgentRuntime,
AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest,
AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult,
AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest,
AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentSessionRevertRequest,
AgentSessionRevertResult, AgentSessionUsageRequest, AgentTurnCancellationRequest,
AgentTurnSettlementRequest, AgentUserAnswersRequest, AgentUserShellCommandRequest,
AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest,
AgentWorkspaceReferenceSearchResult, PermissionReply, PermissionRequest,
PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript,
SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot,
AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver,
AgentInputAttachment, AgentLocalCommandTurnRecordRequest,
AgentMessageWorkspaceReferencesRequest, AgentRuntime, AgentSessionCompactionRequest,
AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest,
AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest,
AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest,
AgentSessionRestoreRequest, AgentSessionRevertRequest, AgentSessionRevertResult,
AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest,
AgentUserAnswersRequest, AgentUserShellCommandRequest, AgentWorkspaceReference,
AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, DialogSteerOutcome,
PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind,
RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport,
WorkspaceDiffSnapshot,
};
use bitfun_agent_runtime_ipc::{
RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode,
Expand Down Expand Up @@ -1319,6 +1320,55 @@ impl CliAgentRuntimeClient {
submission
}

pub(crate) async fn steer_current_turn(
&self,
content: String,
display_content: Option<String>,
) -> Result<String> {
if content.trim().is_empty() {
return Err(anyhow::anyhow!("Steering content cannot be empty"));
}
let session_id = self
.session_id
.lock()
.await
.clone()
.ok_or_else(|| anyhow::anyhow!("No active session is available for steering"))?;
let turn_id = self
.current_turn_id
.lock()
.await
.clone()
.ok_or_else(|| anyhow::anyhow!("No active turn is available for steering"))?;
let request = AgentDialogSteerRequest {
session_id: session_id.clone(),
turn_id: turn_id.clone(),
content,
display_content,
};

match &self.backend {
CliAgentRuntimeBackend::Embedded(runtime) => match runtime
.steer_dialog_turn(request)
.await
.map_err(|error| anyhow::anyhow!(error.into_message()))?
{
DialogSteerOutcome::Buffered { steering_id, .. } => Ok(steering_id),
},
CliAgentRuntimeBackend::Shared(client) => match client
.request(RuntimeIpcOperation::SteerTurn { request })
.await?
{
RuntimeIpcOperationResult::TurnSteered {
session_id: steered_session,
turn_id: steered_turn,
steering_id,
} if steered_session == session_id && steered_turn == turn_id => Ok(steering_id),
_ => Err(unexpected_shared_result("steer_turn")),
},
}
}

pub(crate) async fn run_user_shell_command(
&self,
command: String,
Expand Down Expand Up @@ -1966,6 +2016,27 @@ mod tests {
assert!(!compact.contains("serde_json::from_value"));
}

#[test]
fn steering_uses_the_existing_runtime_contract_in_both_deployments() {
let source = include_str!("runtime_client.rs").replace("\r\n", "\n");
let steering = source
.split_once("pub(crate) async fn steer_current_turn(")
.expect("steering method")
.1
.split_once("pub(crate) async fn run_user_shell_command(")
.expect("steering method boundary")
.0;

assert!(steering.contains("AgentDialogSteerRequest"));
assert!(steering.contains("CliAgentRuntimeBackend::Embedded(runtime)"));
assert!(steering.contains(".steer_dialog_turn(request)"));
assert!(steering.contains("CliAgentRuntimeBackend::Shared(client)"));
assert!(steering.contains("RuntimeIpcOperation::SteerTurn { request }"));
assert!(steering.contains("RuntimeIpcOperationResult::TurnSteered"));
assert!(!steering.contains("RuntimeIpcOperation::SubmitTurn"));
assert!(!steering.contains("Uuid::new_v4"));
}

#[test]
fn image_attachments_use_the_runtime_contract_and_fail_before_shared_ipc() {
let source = include_str!("runtime_client.rs").replace("\r\n", "\n");
Expand Down
95 changes: 94 additions & 1 deletion src/apps/cli/src/chat_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ pub(crate) enum FlowItem {
Text { content: String, is_streaming: bool },
/// AI thinking/reasoning block
Thinking { content: String },
/// User steering injected between model-round flow items.
UserSteering {
steering_id: String,
content: String,
is_pending: bool,
},
/// Tool call block
Tool { tool_state: ToolDisplayState },
}
Expand Down Expand Up @@ -307,7 +313,9 @@ fn visible_message_text(message: &ChatMessage) -> String {
.iter()
.filter_map(|item| match item {
FlowItem::Text { content, .. } => Some(content.as_str()),
FlowItem::Thinking { .. } | FlowItem::Tool { .. } => None,
FlowItem::Thinking { .. } | FlowItem::UserSteering { .. } | FlowItem::Tool { .. } => {
None
}
})
.collect::<Vec<_>>()
.join("\n")
Expand Down Expand Up @@ -896,6 +904,50 @@ impl ChatState {
self.rebuild_streaming_message();
}

/// Add an optimistic steering item or upgrade it when the runtime emits
/// the authoritative injection event. Returns true only for a new item.
pub(crate) fn handle_user_steering(
&mut self,
steering_id: &str,
content: &str,
is_pending: bool,
) -> bool {
if !self.is_processing || self.current_turn_id.is_none() {
return false;
}
if let Some(existing) = self.current_flow_items.iter_mut().find(|item| {
matches!(
item,
FlowItem::UserSteering {
steering_id: existing_id,
..
} if existing_id == steering_id
)
}) {
if let FlowItem::UserSteering {
content: existing_content,
is_pending: existing_pending,
..
} = existing
{
*existing_content = content.to_string();
if !is_pending {
*existing_pending = false;
}
}
self.rebuild_streaming_message();
return false;
}

self.current_flow_items.push(FlowItem::UserSteering {
steering_id: steering_id.to_string(),
content: content.to_string(),
is_pending,
});
self.rebuild_streaming_message();
true
}

/// Handle a tool event.
/// New tools are appended to current_flow_items in chronological order.
/// Existing tools are updated in-place via tool_index for O(1) lookup.
Expand Down Expand Up @@ -1829,6 +1881,47 @@ mod tests {
assert_create_plan_item(&state.current_flow_items[0]);
}

#[test]
fn user_steering_is_deduplicated_and_preserves_stream_order() {
let mut state = ChatState::new(
"session-1".to_string(),
"Session".to_string(),
"agentic".to_string(),
None,
);
state.handle_turn_started("turn-1", "Start the task");
state.handle_text_chunk("Before steering");

assert!(state.handle_user_steering("steer-1", "Also check tests", true));
assert!(!state.handle_user_steering("steer-1", "Also check tests", false));
state.handle_text_chunk("After steering");

assert!(matches!(
state.current_flow_items.as_slice(),
[
FlowItem::Text { content: before, .. },
FlowItem::UserSteering {
steering_id,
content,
is_pending: false,
},
FlowItem::Text { content: after, .. },
] if before == "Before steering"
&& steering_id == "steer-1"
&& content == "Also check tests"
&& after == "After steering"
));
assert_eq!(
state.current_flow_items.len(),
state
.messages
.last()
.expect("assistant message")
.flow_items
.len()
);
}

#[test]
fn deferred_history_projects_effective_view_without_mutating_wire_message() {
let wire_input = deferred_input();
Expand Down
39 changes: 26 additions & 13 deletions src/apps/cli/src/dispatch/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ use std::time::Duration;

use anyhow::{anyhow, bail, Context, Result};
use bitfun_agent_runtime::sdk::{
AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionRestoreRequest,
AgentTurnCancellationRequest, AgentTurnSettlementRequest, PermissionReply,
PermissionReplySource, PermissionRequest, PermissionRequestEvent,
AgentDialogSteerRequest, AgentDialogTurnRequest, AgentSessionCreateRequest,
AgentSessionRestoreRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest,
PermissionReply, PermissionReplySource, PermissionRequest, PermissionRequestEvent,
};
use bitfun_events::{project_agentic_frontend_event, AgenticEvent};
use bitfun_runtime_ports::{
Expand Down Expand Up @@ -364,7 +364,6 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> {
store,
job_id,
&agent_runtime,
&compatibility,
&job.request.session_id,
&turn_id,
).await? {
Expand Down Expand Up @@ -447,7 +446,6 @@ async fn process_mailboxes(
store: &DispatchStore,
job_id: &str,
runtime: &bitfun_agent_runtime::sdk::AgentRuntime,
compatibility: &bitfun_core::product_runtime::CoreAgentRuntimeCompatibility,
session_id: &str,
turn_id: &str,
) -> Result<Option<(DispatchJobState, Option<String>)>> {
Expand Down Expand Up @@ -483,15 +481,15 @@ async fn process_mailboxes(
}

for request in store.list_pending_append_messages(job_id)? {
compatibility
.submit_steering(
session_id.to_string(),
turn_id.to_string(),
request.content.clone(),
request.display_content.clone(),
)
runtime
.steer_dialog_turn(AgentDialogSteerRequest {
session_id: session_id.to_string(),
turn_id: turn_id.to_string(),
content: request.content.clone(),
display_content: request.display_content.clone(),
})
.await
.map_err(anyhow::Error::msg)
.map_err(|error| anyhow!(error.into_message()))
.with_context(|| {
format!(
"append message {} to running dispatch turn",
Expand Down Expand Up @@ -674,6 +672,21 @@ fn terminal_outcome(
mod tests {
use super::*;

#[test]
fn dispatch_append_reuses_the_runtime_steering_port() {
let source = include_str!("worker.rs").replace("\r\n", "\n");
let mailboxes = source
.split_once("async fn process_mailboxes(")
.expect("mailbox processor")
.1
.split_once("async fn cancel_turn(")
.expect("mailbox processor boundary")
.0;

assert!(mailboxes.contains(".steer_dialog_turn(AgentDialogSteerRequest"));
assert!(!mailboxes.contains("compatibility.submit_steering"));
}

#[test]
fn terminal_events_map_to_persistent_job_states() {
let completed = AgenticEvent::DialogTurnCompleted {
Expand Down
Loading
Loading