feat(agent): add in-run user guidance steering

This commit is contained in:
Darkatse 2026-06-19 00:19:17 +08:00
parent c7615089ae
commit c62d23deb0
30 changed files with 1344 additions and 5 deletions

View file

@ -25,6 +25,7 @@ type TauriTavernAgentApi = {
startRunWithPromptSnapshot(input: AgentStartRunWithPromptSnapshotInput): Promise<AgentRunHandle>;
subscribe(runId: string, handler: (event: AgentRunEvent) => void, options?: AgentSubscribeOptions): TauriTavernHostUnsubscribe;
cancel(runId: string): Promise<AgentRunHandle>;
submitGuidance(input: AgentSubmitGuidanceInput): Promise<AgentSubmitGuidanceResult>;
readEvents(input: AgentReadEventsInput): Promise<AgentReadEventsResult>;
readWorkspaceFile(input: AgentReadWorkspaceFileInput): Promise<AgentWorkspaceFile>;
readModelTurn(input: AgentReadModelTurnInput): Promise<AgentModelTurn>;
@ -248,6 +249,43 @@ await agent.cancel(runId);
- Cancel 后不能自动 commit。
- 返回最新 `AgentRunHandle`。
## 6.1 submitGuidance
```ts
await agent.submitGuidance({
runId,
text: "Prefer the quieter ending and keep the character's agency clear.",
clientGuidanceId: "optional-client-correlation-id",
});
```
```ts
type AgentSubmitGuidanceInput = {
runId: string;
text: string;
clientGuidanceId?: string;
};
type AgentSubmitGuidanceResult = {
runId: string;
guidanceId: string;
clientGuidanceId?: string;
status: 'queued';
preview: string;
chars: number;
words: number;
pendingCount: number;
};
```
语义:
- `submitGuidance()` 是 active AgentRun 的 run-scoped 输入通道,不是普通聊天发送,不写入 SillyTavern chat history,也不会启动新的 Generate。
- Runtime 会先写 `user_guidance_submitted`,再将 guidance 放入当前 active run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,pending guidance 会合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。
- 已经发出的 provider request 不会被热修改。若当前模型调用正在进行,guidance 只影响后续模型请求边界。
- `cancel()`、run finish、run failure / partial success 会关闭 mailbox;尚未应用的 guidance 写 `user_guidance_discarded`。
- 空文本、过长文本、非 active run、`finishing` / `cancelling` / terminal run、mailbox 已满都会 fail-fast,不做静默丢弃。
## 7. approveToolCall
当前未实现审批流程;`approveToolCall()` 会显式 throw。

View file

@ -289,6 +289,20 @@ run_failed
- 恢复失败(模型再次返回 0 tool_calls 且已没有下一轮预算)→ 若没有成功 chat commit,写 `run_failed`(`userRetryable=true`);若已有成功 chat commit,写 `run_partial_success` 并保留输出
- 上述 rollback / partial-success 语义与前端入口无关;普通发送、`/trigger`、regenerate 与 overswipe 只要进入 Agent run,都必须遵守同一 journal 与 host commit 契约。
### 4.1.1 User Guidance
```text
user_guidance_submitted
user_guidance_applied
user_guidance_discarded
```
User guidance 是 active AgentRun 的 run-scoped 用户输入,不是普通聊天消息。提交成功后 runtime 必须先写 `user_guidance_submitted`,再将 guidance 放入当前 run mailbox;下一次 root / handoff 前台 invocation 创建模型请求前,runtime 将 pending guidance 合并为一条 canonical `role=user` message,并写 `user_guidance_applied`。
已经发出的 provider request 不可被热修改。若 guidance 在模型调用、工具调用、host commit 等过程中提交,只能影响后续安全模型请求边界。run cancel、finish、failure / partial success 会关闭 mailbox,并对尚未应用的 guidance 写 `user_guidance_discarded`。
事件 payload 必须包含 `guidanceId` 或 `guidanceIds`、`chars`、`words`、`preview`、`status`,并带 `invocationId` / canonical `eventScope` 以便 Timeline 可以归入前台 Agent 链。`user_guidance_submitted` 在 V1 直接内联受长度限制的完整 `text`,便于 Timeline detail 和审计读取;applied / discarded 事件用 ids、统计和 preview 关联,不重复写全文。guidance 文本不应写入普通 chat history。
### 4.2 Workspace
```text

View file

@ -37,6 +37,7 @@ api.agent.startRunFromLegacyGenerate(input?)
api.agent.startRunWithPromptSnapshot(input)
api.agent.subscribe(runId, handler, options?)
api.agent.cancel(runId)
api.agent.submitGuidance(input)
api.agent.listRuns(input?)
api.agent.retention.readSettings()
api.agent.retention.updateSettings(input)

View file

@ -232,6 +232,29 @@ pub struct AgentRunHandleDto {
pub status: AgentRunStatus,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSubmitGuidanceDto {
pub run_id: String,
pub text: String,
#[serde(default)]
pub client_guidance_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentSubmitGuidanceResultDto {
pub run_id: String,
pub guidance_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_guidance_id: Option<String>,
pub status: String,
pub preview: String,
pub chars: usize,
pub words: usize,
pub pending_count: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentListRunsDto {

View file

@ -31,6 +31,7 @@ mod commit_ledger;
mod delegation;
mod error_payload;
mod executor;
mod guidance;
mod input_context;
mod invocation;
mod journal;

View file

@ -333,6 +333,12 @@ impl AgentRuntimeService {
cancel: &mut AgentCancelReceiver,
) -> Result<(), ApplicationError> {
self.cancel_unfinished_child_tasks(run_id).await?;
self.close_guidance_mailbox_for_run(
run_id,
"run_finished_before_next_model_request",
AgentRunEventLevel::Info,
)
.await?;
self.transition_status(run_id, AgentRunStatus::Finishing)
.await?;
let run = self.run_repository.load_run(run_id).await?;

View file

@ -56,6 +56,13 @@ impl AgentRuntimeService {
match result {
Ok(()) => {}
Err(ApplicationError::Cancelled(message)) => {
let _ = self
.close_guidance_mailbox_for_run(
run_id,
"run_cancelled_before_next_model_request",
AgentRunEventLevel::Info,
)
.await;
let _ = self.cancel_unfinished_child_tasks(run_id).await;
let _ = self
.finish_root_invocation(run_id, AgentInvocationStatus::Cancelled)
@ -75,6 +82,18 @@ impl AgentRuntimeService {
self.active_runs.write().await.remove(run_id);
}
Err(error) => {
let guidance_discard_reason = if commit_ledger.is_empty() {
"run_failed_before_next_model_request"
} else {
"run_partial_success_before_next_model_request"
};
let _ = self
.close_guidance_mailbox_for_run(
run_id,
guidance_discard_reason,
AgentRunEventLevel::Warn,
)
.await;
let _ = self.cancel_unfinished_child_tasks(run_id).await;
let _ = self
.finish_root_invocation(run_id, AgentInvocationStatus::Failed)

View file

@ -0,0 +1,412 @@
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use uuid::Uuid;
use super::AgentRuntimeService;
use crate::application::dto::agent_dto::{AgentSubmitGuidanceDto, AgentSubmitGuidanceResultDto};
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{
AgentModelContentPart, AgentModelMessage, AgentModelRequest, AgentModelRole,
AgentRunEventLevel, AgentRunStatus, ROOT_AGENT_INVOCATION_ID,
};
use crate::domain::text_metrics::TextMetrics;
const MAX_GUIDANCE_TEXT_CHARS: usize = 16_000;
const MAX_PENDING_GUIDANCE_ITEMS: usize = 8;
const MAX_PENDING_GUIDANCE_CHARS: usize = 64_000;
const GUIDANCE_PREVIEW_CHARS: usize = 240;
const USER_GUIDANCE_OPEN_TAG: &str = "<user_guidance>";
const USER_GUIDANCE_CLOSE_TAG: &str = "</user_guidance>";
#[derive(Debug, Clone)]
pub(super) struct AgentGuidanceItem {
pub(super) guidance_id: String,
pub(super) client_guidance_id: Option<String>,
pub(super) text: String,
pub(super) preview: String,
pub(super) metrics: TextMetrics,
pub(super) submitted_at: DateTime<Utc>,
}
#[derive(Debug, Default)]
pub(super) struct AgentGuidanceMailbox {
state: Mutex<AgentGuidanceMailboxState>,
}
#[derive(Debug, Default)]
struct AgentGuidanceMailboxState {
queue: VecDeque<AgentGuidanceItem>,
pending_chars: usize,
closed: bool,
}
impl AgentGuidanceMailbox {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) async fn ensure_can_accept(&self, chars: usize) -> Result<(), ApplicationError> {
let state = self.state.lock().await;
validate_accepts(&state, chars)
}
pub(super) async fn enqueue(&self, item: AgentGuidanceItem) -> Result<usize, ApplicationError> {
let mut state = self.state.lock().await;
validate_accepts(&state, item.metrics.chars)?;
state.pending_chars += item.metrics.chars;
state.queue.push_back(item);
Ok(state.queue.len())
}
pub(super) async fn drain(&self) -> Vec<AgentGuidanceItem> {
let mut state = self.state.lock().await;
let items = state.queue.drain(..).collect::<Vec<_>>();
state.pending_chars = 0;
items
}
pub(super) async fn close_and_drain(&self) -> Vec<AgentGuidanceItem> {
let mut state = self.state.lock().await;
state.closed = true;
let items = state.queue.drain(..).collect::<Vec<_>>();
state.pending_chars = 0;
items
}
}
impl AgentRuntimeService {
pub async fn submit_guidance(
&self,
dto: AgentSubmitGuidanceDto,
) -> Result<AgentSubmitGuidanceResultDto, ApplicationError> {
let run_id = normalize_required("runId", &dto.run_id)?;
let text = normalize_guidance_text(&dto.text)?;
let metrics = TextMetrics::from_text(&text);
if metrics.chars > MAX_GUIDANCE_TEXT_CHARS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_too_large: guidance is {} chars; maximum is {}",
metrics.chars, MAX_GUIDANCE_TEXT_CHARS
)));
}
let run = self.run_repository.load_run(run_id.as_str()).await?;
if !run_status_accepts_guidance(run.status) {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_run_not_accepting: run `{}` is {} and cannot accept guidance",
run.id,
serde_json::to_string(&run.status)
.unwrap_or_else(|_| "unknown".to_string())
.trim_matches('"')
)));
}
let active_handle = self.active_runs.read().await.get(&run.id).cloned();
let active_handle = active_handle.ok_or_else(|| {
ApplicationError::ValidationError(format!(
"agent.guidance_active_run_missing: run `{}` is not active",
run.id
))
})?;
active_handle
.guidance_mailbox
.ensure_can_accept(metrics.chars)
.await?;
let item = AgentGuidanceItem {
guidance_id: format!("guidance_{}", Uuid::new_v4().simple()),
client_guidance_id: normalize_optional(dto.client_guidance_id.as_deref()),
preview: guidance_preview(&text),
text,
metrics,
submitted_at: Utc::now(),
};
self.event(
&run.id,
AgentRunEventLevel::Info,
"user_guidance_submitted",
json!({
"guidanceId": item.guidance_id.as_str(),
"clientGuidanceId": item.client_guidance_id.as_deref(),
"invocationId": ROOT_AGENT_INVOCATION_ID,
"chars": item.metrics.chars,
"words": item.metrics.words,
"preview": item.preview.as_str(),
"text": item.text.as_str(),
"submittedAt": item.submitted_at,
"status": "queued",
}),
)
.await?;
let pending_count = match active_handle.guidance_mailbox.enqueue(item.clone()).await {
Ok(pending_count) => pending_count,
Err(error) => {
self.emit_guidance_discarded(
&run.id,
&[item],
"submit_rejected_after_journal_append",
AgentRunEventLevel::Warn,
)
.await?;
return Err(error);
}
};
Ok(AgentSubmitGuidanceResultDto {
run_id: run.id,
guidance_id: item.guidance_id,
client_guidance_id: item.client_guidance_id,
status: "queued".to_string(),
preview: item.preview,
chars: item.metrics.chars,
words: item.metrics.words,
pending_count,
})
}
pub(super) async fn apply_pending_guidance_to_request(
&self,
run_id: &str,
invocation_id: &str,
round: usize,
request: &mut AgentModelRequest,
) -> Result<(), ApplicationError> {
let items = self.drain_pending_guidance(run_id).await;
if items.is_empty() {
return Ok(());
}
let message_index = request.messages.len();
let message_text = build_guidance_message(&items);
request.messages.push(AgentModelMessage {
role: AgentModelRole::User,
parts: vec![AgentModelContentPart::Text { text: message_text }],
provider_metadata: Value::Null,
});
let summary = guidance_batch_summary(&items);
self.event(
run_id,
AgentRunEventLevel::Info,
"user_guidance_applied",
json!({
"guidanceIds": summary.guidance_ids,
"clientGuidanceIds": summary.client_guidance_ids,
"invocationId": invocation_id,
"round": round,
"count": summary.count,
"chars": summary.chars,
"words": summary.words,
"preview": summary.preview,
"messageIndex": message_index,
"requestMessageCount": request.messages.len(),
"status": "applied",
}),
)
.await?;
Ok(())
}
pub(super) async fn close_guidance_mailbox_for_run(
&self,
run_id: &str,
reason: &str,
level: AgentRunEventLevel,
) -> Result<(), ApplicationError> {
let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else {
return Ok(());
};
let items = handle.guidance_mailbox.close_and_drain().await;
if items.is_empty() {
return Ok(());
}
self.emit_guidance_discarded(run_id, &items, reason, level)
.await
}
async fn drain_pending_guidance(&self, run_id: &str) -> Vec<AgentGuidanceItem> {
let Some(handle) = self.active_runs.read().await.get(run_id).cloned() else {
return Vec::new();
};
handle.guidance_mailbox.drain().await
}
async fn emit_guidance_discarded(
&self,
run_id: &str,
items: &[AgentGuidanceItem],
reason: &str,
level: AgentRunEventLevel,
) -> Result<(), ApplicationError> {
let summary = guidance_batch_summary(items);
self.event(
run_id,
level,
"user_guidance_discarded",
json!({
"guidanceIds": summary.guidance_ids,
"clientGuidanceIds": summary.client_guidance_ids,
"invocationId": ROOT_AGENT_INVOCATION_ID,
"count": summary.count,
"chars": summary.chars,
"words": summary.words,
"preview": summary.preview,
"reason": reason,
"status": "discarded",
}),
)
.await?;
Ok(())
}
}
fn validate_accepts(
state: &AgentGuidanceMailboxState,
incoming_chars: usize,
) -> Result<(), ApplicationError> {
if state.closed {
return Err(ApplicationError::ValidationError(
"agent.guidance_mailbox_closed: run is no longer accepting guidance".to_string(),
));
}
if state.queue.len() >= MAX_PENDING_GUIDANCE_ITEMS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_mailbox_full: pending guidance item limit {} reached",
MAX_PENDING_GUIDANCE_ITEMS
)));
}
if state.pending_chars + incoming_chars > MAX_PENDING_GUIDANCE_CHARS {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_mailbox_full: pending guidance char limit {} exceeded",
MAX_PENDING_GUIDANCE_CHARS
)));
}
Ok(())
}
fn run_status_accepts_guidance(status: AgentRunStatus) -> bool {
!matches!(
status,
AgentRunStatus::Finishing
| AgentRunStatus::Completed
| AgentRunStatus::PartialSuccess
| AgentRunStatus::Cancelling
| AgentRunStatus::Cancelled
| AgentRunStatus::Failed
)
}
fn normalize_required(field: &str, value: &str) -> Result<String, ApplicationError> {
let value = value.trim();
if value.is_empty() {
return Err(ApplicationError::ValidationError(format!(
"agent.guidance_invalid: {field} is required"
)));
}
Ok(value.to_string())
}
fn normalize_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn normalize_guidance_text(value: &str) -> Result<String, ApplicationError> {
let text = value.trim();
if text.is_empty() {
return Err(ApplicationError::ValidationError(
"agent.guidance_empty: guidance text cannot be empty".to_string(),
));
}
Ok(text.to_string())
}
fn guidance_preview(text: &str) -> String {
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_chars(&collapsed, GUIDANCE_PREVIEW_CHARS)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
text.chars().take(max_chars).collect::<String>()
}
fn build_guidance_message(items: &[AgentGuidanceItem]) -> String {
let mut text = String::from(USER_GUIDANCE_OPEN_TAG);
text.push_str(
"\nThe user sent the following guidance while you were working. \
Apply the guidance in order as the user's latest direction for your next step, \
within your existing instructions and tool rules.",
);
if items.len() == 1 {
text.push_str("\n\n");
text.push_str(&items[0].text);
text.push('\n');
text.push_str(USER_GUIDANCE_CLOSE_TAG);
return text;
}
for (index, item) in items.iter().enumerate() {
text.push_str(&format!(
"\n\n<guidance index=\"{}\">\n{}\n</guidance>",
index + 1,
item.text
));
}
text.push('\n');
text.push_str(USER_GUIDANCE_CLOSE_TAG);
text
}
struct GuidanceBatchSummary {
guidance_ids: Vec<String>,
client_guidance_ids: Vec<String>,
count: usize,
chars: usize,
words: usize,
preview: String,
}
fn guidance_batch_summary(items: &[AgentGuidanceItem]) -> GuidanceBatchSummary {
let guidance_ids = items
.iter()
.map(|item| item.guidance_id.clone())
.collect::<Vec<_>>();
let client_guidance_ids = items
.iter()
.filter_map(|item| item.client_guidance_id.clone())
.collect::<Vec<_>>();
let chars = items.iter().map(|item| item.metrics.chars).sum();
let words = items.iter().map(|item| item.metrics.words).sum();
let preview = if items.len() == 1 {
items[0].preview.clone()
} else {
guidance_preview(
&items
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>()
.join("\n\n"),
)
};
GuidanceBatchSummary {
guidance_ids,
client_guidance_ids,
count: items.len(),
chars,
words,
preview,
}
}

View file

@ -255,6 +255,12 @@ impl AgentRuntimeService {
let active_handle = self.active_runs.read().await.get(&dto.run_id).cloned();
let next = if let Some(handle) = active_handle {
self.close_guidance_mailbox_for_run(
&dto.run_id,
"run_cancel_requested",
AgentRunEventLevel::Info,
)
.await?;
let _ = handle.cancel_sender.send(true);
handle.scheduler.cancel_all_unfinished().await?;
self.transition_status(&dto.run_id, AgentRunStatus::Cancelling)

View file

@ -52,6 +52,10 @@ impl AgentRuntimeService {
let mut drift_recovery_attempts: usize = 0;
for round in 1..=profile.tools.max_rounds {
let updates_run_status = exit_policy == AgentInvocationExitPolicy::RunFinishAllowed;
if updates_run_status {
self.apply_pending_guidance_to_request(run_id, invocation_id, round, &mut request)
.await?;
}
if updates_run_status {
self.transition_status(run_id, AgentRunStatus::CallingModel)
.await?;

View file

@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, Weak};
use tokio::sync::watch;
use super::AgentRuntimeService;
use super::guidance::AgentGuidanceMailbox;
use crate::application::errors::ApplicationError;
use crate::domain::models::agent::{
AgentDelegationContinuation, AgentInvocationStatus, AgentTaskRecord, AgentTaskStatus,
@ -13,6 +14,7 @@ use crate::domain::models::agent::{
pub(super) struct ActiveRunHandle {
pub(super) cancel_sender: watch::Sender<bool>,
pub(super) scheduler: Arc<AgentTaskScheduler>,
pub(super) guidance_mailbox: Arc<AgentGuidanceMailbox>,
}
impl ActiveRunHandle {
@ -24,6 +26,7 @@ impl ActiveRunHandle {
Self {
cancel_sender,
scheduler: Arc::new(AgentTaskScheduler::new(service, run_id)),
guidance_mailbox: Arc::new(AgentGuidanceMailbox::new()),
}
}
}

View file

@ -15,10 +15,10 @@ use super::commit_ledger::RunCommitLedger;
use super::delegation::workspace_policy::InvocationWorkspaceRepository;
use super::skill_scope::{resolve_run_skill_scope_refs, skill_scope_order_for_profile};
use crate::application::dto::agent_dto::{
AgentPromptAssemblyScopeDto, AgentReadEventsDto, AgentReadModelTurnDto,
AgentCancelRunDto, AgentPromptAssemblyScopeDto, AgentReadEventsDto, AgentReadModelTurnDto,
AgentReadPromptAssemblyRequestDto, AgentResolveChatCommitDto,
AgentResolvePersistentStateMetadataUpdateDto, AgentResolvePromptAssemblyDto,
AgentSkillScopeRefsDto, AgentStartRunDto, AgentStartRunOptionsDto,
AgentSkillScopeRefsDto, AgentStartRunDto, AgentStartRunOptionsDto, AgentSubmitGuidanceDto,
};
use crate::application::dto::chat_completion_dto::ChatCompletionGenerateRequestDto;
use crate::application::errors::ApplicationError;
@ -7811,6 +7811,279 @@ async fn return_mode_child_write_rejects_non_writable_visible_root() {
tokio::fs::remove_dir_all(root).await.expect("cleanup");
}
#[tokio::test]
async fn submitted_guidance_is_applied_to_next_model_request() {
let root = std::env::temp_dir().join(format!(
"tauritavern-agent-guidance-apply-{}",
Uuid::new_v4().simple()
));
let repository = Arc::new(FileAgentRepository::new(root.clone()));
let model_gateway = Arc::new(MockAgentModelGateway::new(vec![json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_finish_guidance",
"type": "function",
"function": {
"name": "workspace_finish",
"arguments": "{}"
}
}]
}
}]
})]));
let model_gateway_probe = model_gateway.clone();
let service = Arc::new(AgentRuntimeService::new(
repository.clone(),
repository.clone(),
repository.clone(),
repository.clone(),
test_chat_repository(&root),
test_chat_repository(&root),
test_skill_service(&root),
model_gateway,
test_profile_service(&root),
test_llm_connection_service(&root),
));
let run = guidance_test_run("run_guidance_apply");
repository.create_run(&run).await.expect("create run");
insert_active_run_handle(&service, &run.id).await;
let submitted = service
.submit_guidance(AgentSubmitGuidanceDto {
run_id: run.id.clone(),
text: "Focus the next step on the user's revised direction.".to_string(),
client_guidance_id: Some("client-guidance-apply".to_string()),
})
.await
.expect("submit guidance");
assert_eq!(submitted.status, "queued");
assert_eq!(submitted.pending_count, 1);
let request = ChatCompletionGenerateRequestDto {
payload: json!({
"chat_completion_source": "openai",
"model": "test-model",
"messages": prompt_messages("write the initial draft")
})
.as_object()
.cloned()
.unwrap(),
};
let prompt_snapshot = json!({ "chatCompletionPayload": request.payload.clone() });
let (_cancel_sender, mut cancel_receiver) = watch::channel(false);
let profile = test_resolved_profile(&root).await;
service
.execute_agent_loop_run_inner(
&run.id,
prompt_snapshot,
request,
profile,
&mut cancel_receiver,
)
.await
.expect("agent loop");
let requests = model_gateway_probe.requests().await;
assert_eq!(requests.len(), 1);
let user_texts = requests[0]
.messages
.iter()
.filter(|message| message.role == AgentModelRole::User)
.filter_map(first_text_part)
.collect::<Vec<_>>();
let guidance_message = user_texts.last().expect("guidance user message");
assert_eq!(
*guidance_message,
concat!(
"<user_guidance>\n",
"The user sent the following guidance while you were working. ",
"Apply the guidance in order as the user's latest direction for your next step, ",
"within your existing instructions and tool rules.\n\n",
"Focus the next step on the user's revised direction.\n",
"</user_guidance>"
)
);
let events = repository
.read_events(
&run.id,
AgentRunEventReadQuery {
after_seq: Some(0),
before_seq: None,
limit: 100,
invocation_id: None,
},
)
.await
.expect("read events");
let submitted_event_index = events
.iter()
.position(|event| event.event_type == "user_guidance_submitted")
.expect("submitted event");
let applied_event_index = events
.iter()
.position(|event| event.event_type == "user_guidance_applied")
.expect("applied event");
let request_event_index = events
.iter()
.position(|event| event.event_type == "model_request_created")
.expect("model request event");
assert!(submitted_event_index < applied_event_index);
assert!(applied_event_index < request_event_index);
let submitted_event = events
.iter()
.find(|event| event.event_type == "user_guidance_submitted")
.expect("submitted event");
assert_eq!(
submitted_event.payload["text"],
"Focus the next step on the user's revised direction."
);
let applied = events
.iter()
.find(|event| event.event_type == "user_guidance_applied")
.expect("applied event");
assert_eq!(
applied.payload["guidanceIds"],
json!([submitted.guidance_id])
);
assert_eq!(
applied.payload["clientGuidanceIds"],
json!(["client-guidance-apply"])
);
assert_eq!(applied.payload["count"], 1);
assert_eq!(applied.payload["status"], "applied");
let model_request = events
.iter()
.find(|event| event.event_type == "model_request_created")
.expect("model request event");
assert_eq!(model_request.payload["request"]["messageCount"], 3);
assert!(
!events
.iter()
.any(|event| event.event_type == "user_guidance_discarded")
);
tokio::fs::remove_dir_all(root).await.expect("cleanup");
}
#[tokio::test]
async fn cancel_run_discards_pending_guidance_and_closes_mailbox() {
let root = std::env::temp_dir().join(format!(
"tauritavern-agent-guidance-cancel-{}",
Uuid::new_v4().simple()
));
let repository = Arc::new(FileAgentRepository::new(root.clone()));
let service = Arc::new(AgentRuntimeService::new(
repository.clone(),
repository.clone(),
repository.clone(),
repository.clone(),
test_chat_repository(&root),
test_chat_repository(&root),
test_skill_service(&root),
Arc::new(MockAgentModelGateway::new(vec![])),
test_profile_service(&root),
test_llm_connection_service(&root),
));
let run = guidance_test_run("run_guidance_cancel");
repository.create_run(&run).await.expect("create run");
insert_active_run_handle(&service, &run.id).await;
let submitted = service
.submit_guidance(AgentSubmitGuidanceDto {
run_id: run.id.clone(),
text: "Change direction before the next model call.".to_string(),
client_guidance_id: Some("client-guidance-cancel".to_string()),
})
.await
.expect("submit guidance");
let cancelled = service
.cancel_run(AgentCancelRunDto {
run_id: run.id.clone(),
})
.await
.expect("cancel run");
assert_eq!(cancelled.status, AgentRunStatus::Cancelling);
let retry_error = service
.submit_guidance(AgentSubmitGuidanceDto {
run_id: run.id.clone(),
text: "This should be rejected after cancellation.".to_string(),
client_guidance_id: None,
})
.await
.expect_err("cancelled run must reject guidance");
assert!(
retry_error
.to_string()
.contains("agent.guidance_run_not_accepting")
);
let events = repository
.read_events(
&run.id,
AgentRunEventReadQuery {
after_seq: Some(0),
before_seq: None,
limit: 100,
invocation_id: None,
},
)
.await
.expect("read events");
let discarded = events
.iter()
.find(|event| event.event_type == "user_guidance_discarded")
.expect("discarded event");
assert_eq!(
discarded.payload["guidanceIds"],
json!([submitted.guidance_id])
);
assert_eq!(
discarded.payload["clientGuidanceIds"],
json!(["client-guidance-cancel"])
);
assert_eq!(discarded.payload["reason"], "run_cancel_requested");
assert_eq!(discarded.payload["status"], "discarded");
tokio::fs::remove_dir_all(root).await.expect("cleanup");
}
fn guidance_test_run(run_id: &str) -> AgentRun {
let now = Utc::now();
AgentRun {
id: run_id.to_string(),
workspace_id: format!("chat_{run_id}"),
stable_chat_id: format!("stable_{run_id}"),
chat_ref: AgentChatRef::Character {
character_id: "Seraphina".to_string(),
file_name: "Seraphina.png".to_string(),
},
generation_type: "normal".to_string(),
profile_id: None,
skill_scope_refs: AgentRunSkillScopeRefs::default(),
persist_base_state_id: None,
input_message_count: None,
presentation: AgentRunPresentation::Background,
status: AgentRunStatus::Created,
created_at: now,
updated_at: now,
}
}
fn first_text_part(message: &crate::domain::models::agent::AgentModelMessage) -> Option<&str> {
message.parts.iter().find_map(|part| match part {
AgentModelContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
}
fn prompt_messages(user_content: &str) -> Value {
json!([
{

View file

@ -17,7 +17,8 @@ use crate::application::dto::agent_dto::{
AgentResolvePersistentStateMetadataUpdateDto, AgentResolvePromptAssemblyDto,
AgentResolveSystemPromptDto, AgentResolveSystemPromptResultDto, AgentRetargetPresetRefsDto,
AgentRetargetPresetRefsResultDto, AgentRunHandleDto, AgentRunPruneApplyResultDto,
AgentRunPrunePlanDto, AgentSaveProfileDto, AgentStartRunDto, AgentWorkspaceFileDto,
AgentRunPrunePlanDto, AgentSaveProfileDto, AgentStartRunDto, AgentSubmitGuidanceDto,
AgentSubmitGuidanceResultDto, AgentWorkspaceFileDto,
};
use crate::application::errors::ApplicationError;
use crate::application::services::agent_workspace_lifecycle_service::AgentChatWorkspaceTarget;
@ -233,6 +234,20 @@ pub async fn cancel_agent_run(
.map_err(map_command_error("Failed to cancel agent run"))
}
#[tauri::command]
pub async fn submit_agent_run_guidance(
dto: AgentSubmitGuidanceDto,
app_state: State<'_, Arc<AppState>>,
) -> Result<AgentSubmitGuidanceResultDto, CommandError> {
log_command("submit_agent_run_guidance");
app_state
.agent_runtime_service
.submit_guidance(dto)
.await
.map_err(map_command_error("Failed to submit agent run guidance"))
}
#[tauri::command]
pub async fn list_agent_runs(
dto: AgentListRunsDto,

View file

@ -298,6 +298,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::agent_commands::repair_agent_profile_file,
super::agent_commands::retarget_agent_profile_preset_refs,
super::agent_commands::cancel_agent_run,
super::agent_commands::submit_agent_run_guidance,
super::agent_commands::list_agent_runs,
super::agent_commands::plan_agent_run_prune,
super::agent_commands::apply_agent_run_prune,

View file

@ -52,6 +52,8 @@ import {
cancelActiveAgentRun,
hasActiveAgentRun,
startAndWaitForAgentRun,
submitGuidanceToActiveAgentRun,
subscribeAgentRunState,
} from './scripts/tauritavern/agent/agent-run-controller.js';
import { agentErrorMessage } from './scripts/tauritavern/agent/agent-error-presenter.js';
import { normalizeAgentContextPolicy } from './scripts/tauritavern/agent/agent-context-policy.js';
@ -2211,6 +2213,7 @@ export async function sendTextareaMessage() {
return;
}
if (swipeState !== SWIPE_STATE.NONE) return; // don't proceed if mid-swipe.
if (await maybeSubmitAgentGuidanceFromComposer()) return;
if (is_send_press) return;
if (isExecutingCommandsFromChatInput) return;
@ -2259,6 +2262,56 @@ export async function sendTextareaMessage() {
}
}
function getAgentGuidanceComposerText() {
return String($('#send_textarea').val() ?? '').trim();
}
function shouldOfferAgentGuidanceFromComposer() {
return hasActiveAgentRun()
&& Boolean(getAgentGuidanceComposerText());
}
function syncAgentGuidanceComposerState() {
if (shouldOfferAgentGuidanceFromComposer()) {
document.body.dataset.agentGuidanceReady = 'true';
return;
}
delete document.body.dataset.agentGuidanceReady;
}
async function maybeSubmitAgentGuidanceFromComposer() {
if (!shouldOfferAgentGuidanceFromComposer()) {
syncAgentGuidanceComposerState();
return false;
}
const text = getAgentGuidanceComposerText();
const result = await Popup.show.confirm(
'是否引导Agent行为?',
'<p>这条内容会作为用户指引插入下一次 Agent 模型请求,不会作为普通聊天消息保存。</p>',
{
okButton: '是',
cancelButton: '否',
},
);
if (result !== POPUP_RESULT.AFFIRMATIVE) {
syncAgentGuidanceComposerState();
return true;
}
try {
await submitGuidanceToActiveAgentRun(text);
} catch (error) {
toastr.error(agentErrorMessage(error), t`Agent Mode`);
throw error;
}
$('#send_textarea').val('')[0]?.dispatchEvent(new Event('input', { bubbles: true }));
syncAgentGuidanceComposerState();
return true;
}
/**
* Formats the message text into an HTML string using Markdown and other formatting.
* @param {string} mes Message text
@ -7997,6 +8050,7 @@ export function activateSendButtons() {
hideStopButton();
showSwipeButtons();
delete document.body.dataset.generating;
delete document.body.dataset.agentGuidanceReady;
}
/**
@ -12514,6 +12568,9 @@ jQuery(async function () {
$(document).on('click', '.api_loading', () => cancelStatusCheck('Canceled because connecting was manually canceled'));
installChatInputFocusKeeper();
$('#send_textarea').on('input', syncAgentGuidanceComposerState);
subscribeAgentRunState(syncAgentGuidanceComposerState);
syncAgentGuidanceComposerState();
$('#swipes-checkbox').on('change', function () {
swipes = !!$('#swipes-checkbox').prop('checked');
@ -12544,6 +12601,10 @@ jQuery(async function () {
const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage);
$('#send_but').on('click', async function () {
if (await maybeSubmitAgentGuidanceFromComposer()) {
return;
}
await userInputGenerateMutex.update();
});

View file

@ -228,6 +228,8 @@
"agent_system.timelineDetailEmpty": "此事件没有详情。",
"agent_system.timelineDetailFieldErrorCode": "错误",
"agent_system.timelineDetailFieldAgent": "Agent",
"agent_system.timelineDetailFieldClient": "客户端",
"agent_system.timelineDetailFieldGuidance": "用户指引",
"agent_system.timelineDetailFieldInvocation": "Invocation",
"agent_system.timelineDetailFieldMatches": "匹配",
"agent_system.timelineDetailFieldModel": "模型",
@ -235,6 +237,7 @@
"agent_system.timelineDetailFieldProvider": "Provider",
"agent_system.timelineDetailFieldQuery": "查询",
"agent_system.timelineDetailFieldRange": "范围",
"agent_system.timelineDetailFieldReason": "原因",
"agent_system.timelineDetailFieldReplaceAll": "全部替换",
"agent_system.timelineDetailFieldReplacements": "替换次数",
"agent_system.timelineDetailFieldTextMetrics": "文本",
@ -266,6 +269,9 @@
"agent_system.timelineEventDriftRecoveryAttempted": "正在纠正工具流程({attempt}/{max})",
"agent_system.timelineEventFileWritten": "写入 {path}",
"agent_system.timelineEventGeneric": "运行事件",
"agent_system.timelineEventGuidanceApplied": "用户指引已应用({count})",
"agent_system.timelineEventGuidanceDiscarded": "用户指引已丢弃({count})",
"agent_system.timelineEventGuidanceSubmitted": "用户指引已排队",
"agent_system.timelineEventHandoffAccepted": "已由 {agent} 接力",
"agent_system.timelineEventNarration": "{text}",
"agent_system.timelineEventInvocationCancelled": "Agent 已取消:{agent}",
@ -304,6 +310,7 @@
"agent_system.timelineOpCommit": "提交",
"agent_system.timelineOpDone": "完成",
"agent_system.timelineOpFail": "失败",
"agent_system.timelineOpGuidance": "指引",
"agent_system.timelineOpList": "列表",
"agent_system.timelineOpModel": "模型",
"agent_system.timelineOpNarration": "旁白",
@ -328,6 +335,7 @@
"agent_system.timelineReady": "Agent 已就绪",
"agent_system.timelineReasoning": "推理",
"agent_system.timelineResultText": "结果",
"agent_system.timelineGuidance": "用户指引",
"agent_system.timelineRunning": "Agent 正在运行",
"agent_system.timelineOpenSubAgent": "打开",
"agent_system.timelineStatusCancelled": "已取消",

View file

@ -228,6 +228,8 @@
"agent_system.timelineDetailEmpty": "此事件沒有詳情。",
"agent_system.timelineDetailFieldErrorCode": "錯誤",
"agent_system.timelineDetailFieldAgent": "Agent",
"agent_system.timelineDetailFieldClient": "客戶端",
"agent_system.timelineDetailFieldGuidance": "使用者指引",
"agent_system.timelineDetailFieldInvocation": "Invocation",
"agent_system.timelineDetailFieldMatches": "匹配",
"agent_system.timelineDetailFieldModel": "模型",
@ -235,6 +237,7 @@
"agent_system.timelineDetailFieldProvider": "Provider",
"agent_system.timelineDetailFieldQuery": "查詢",
"agent_system.timelineDetailFieldRange": "範圍",
"agent_system.timelineDetailFieldReason": "原因",
"agent_system.timelineDetailFieldReplaceAll": "全部替換",
"agent_system.timelineDetailFieldReplacements": "替換次數",
"agent_system.timelineDetailFieldTextMetrics": "文本",
@ -266,6 +269,9 @@
"agent_system.timelineEventDriftRecoveryAttempted": "正在糾正工具流程({attempt}/{max})",
"agent_system.timelineEventFileWritten": "寫入 {path}",
"agent_system.timelineEventGeneric": "執行事件",
"agent_system.timelineEventGuidanceApplied": "使用者指引已套用({count})",
"agent_system.timelineEventGuidanceDiscarded": "使用者指引已丟棄({count})",
"agent_system.timelineEventGuidanceSubmitted": "使用者指引已排隊",
"agent_system.timelineEventHandoffAccepted": "已由 {agent} 接力",
"agent_system.timelineEventNarration": "{text}",
"agent_system.timelineEventInvocationCancelled": "Agent 已取消:{agent}",
@ -304,6 +310,7 @@
"agent_system.timelineOpCommit": "提交",
"agent_system.timelineOpDone": "完成",
"agent_system.timelineOpFail": "失敗",
"agent_system.timelineOpGuidance": "指引",
"agent_system.timelineOpList": "列表",
"agent_system.timelineOpModel": "模型",
"agent_system.timelineOpNarration": "旁白",
@ -328,6 +335,7 @@
"agent_system.timelineReady": "Agent 已就緒",
"agent_system.timelineReasoning": "推理",
"agent_system.timelineResultText": "結果",
"agent_system.timelineGuidance": "使用者指引",
"agent_system.timelineRunning": "Agent 正在執行",
"agent_system.timelineOpenSubAgent": "開啟",
"agent_system.timelineStatusCancelled": "已取消",

View file

@ -347,6 +347,8 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineDetailEmpty: 'No details for this event.',
timelineDetailFieldErrorCode: 'Error',
timelineDetailFieldAgent: 'Agent',
timelineDetailFieldClient: 'Client',
timelineDetailFieldGuidance: 'Guidance',
timelineDetailFieldInvocation: 'Invocation',
timelineDetailFieldMatches: 'Matches',
timelineDetailFieldModel: 'Model',
@ -354,6 +356,7 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineDetailFieldProvider: 'Provider',
timelineDetailFieldQuery: 'Query',
timelineDetailFieldRange: 'Range',
timelineDetailFieldReason: 'Reason',
timelineDetailFieldReplaceAll: 'Replace all',
timelineDetailFieldReplacements: 'Replacements',
timelineDetailFieldTextMetrics: 'Text',
@ -385,6 +388,9 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineEventDriftRecoveryAttempted: 'Correcting tool flow ({attempt}/{max})',
timelineEventFileWritten: 'Wrote {path}',
timelineEventGeneric: 'Run event',
timelineEventGuidanceApplied: 'User guidance applied ({count})',
timelineEventGuidanceDiscarded: 'User guidance discarded ({count})',
timelineEventGuidanceSubmitted: 'User guidance queued',
timelineEventHandoffAccepted: 'Handed off to {agent}',
timelineEventNarration: '{text}',
timelineEventInvocationCancelled: 'Agent cancelled: {agent}',
@ -423,6 +429,7 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineOpCommit: 'Commit',
timelineOpDone: 'Done',
timelineOpFail: 'Fail',
timelineOpGuidance: 'Guide',
timelineOpList: 'List',
timelineOpModel: 'Model',
timelineOpNarration: 'Note',
@ -447,6 +454,7 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineReady: 'Agent ready',
timelineReasoning: 'Reasoning',
timelineResultText: 'Result',
timelineGuidance: 'User guidance',
timelineRunning: 'Agent running',
timelineOpenSubAgent: 'Open',
timelineStatusCancelled: 'cancelled',

View file

@ -165,6 +165,51 @@ export function formatHandoffDetail(target) {
};
}
export function formatGuidanceDetail(target) {
const fields = [];
const blocks = [];
const guidanceIds = joinStringArray(target.guidanceIds);
if (guidanceIds) {
fields.push(field(tr('timelineDetailFieldGuidance'), guidanceIds));
}
const clientGuidanceIds = joinStringArray(target.clientGuidanceIds);
if (clientGuidanceIds) {
fields.push(field(tr('timelineDetailFieldClient'), clientGuidanceIds));
}
if (target.status) {
fields.push(field(tr('timelineDetailFieldStatus'), target.status));
}
if (target.invocationId) {
fields.push(field(tr('timelineDetailFieldInvocation'), target.invocationId));
}
if (target.round != null) {
fields.push(field(tr('timelineDetailFieldRound'), target.round));
}
if (target.reason) {
fields.push(field(tr('timelineDetailFieldReason'), target.reason));
}
const metrics = textMetricsSummary(target);
if (metrics) {
fields.push(field(tr('timelineDetailFieldTextMetrics'), metrics));
}
const text = String(target.text || target.preview || '').trim();
if (text) {
addBlock(blocks, 'timelineContent', text, DETAIL_TEXT_LIMIT, false, {
kind: 'user',
});
}
return {
labelKey: target.labelKey,
path: '',
fields,
blocks,
actions: [],
};
}
export function formatPatchDiffDetail(target, file) {
const parsed = parseJson(String(file?.text || ''));
if (!parsed.ok || !plainObject(parsed.value)) {
@ -601,6 +646,13 @@ function field(label, value) {
return { label, value: String(value) };
}
function joinStringArray(value) {
if (!Array.isArray(value)) {
return '';
}
return value.map((item) => String(item || '').trim()).filter(Boolean).join(', ');
}
function formatPrimitive(value) {
if (typeof value === 'boolean') {
return value ? 'yes' : 'no';

View file

@ -31,6 +31,9 @@ const DISPLAY_EVENT_TYPES = new Set([
'chat_commit_failed',
'persistent_changes_committed',
'drift_recovery_attempted',
'user_guidance_submitted',
'user_guidance_applied',
'user_guidance_discarded',
'run_completed',
'run_partial_success',
'run_cancelled',
@ -85,6 +88,9 @@ const EVENT_META = Object.freeze({
chat_commit_failed: { icon: 'fa-circle-exclamation', tone: 'error', kind: 'fail', titleKey: 'timelineEventCommitFailed' },
persistent_changes_committed: { icon: 'fa-database', tone: 'success', kind: 'persist', titleKey: 'timelineEventPersistentCommitted' },
drift_recovery_attempted: { icon: 'fa-arrows-rotate', tone: 'warn', kind: 'recover', titleKey: 'timelineEventDriftRecoveryAttempted' },
user_guidance_submitted: { icon: 'fa-user-pen', tone: 'active', kind: 'guidance', titleKey: 'timelineEventGuidanceSubmitted' },
user_guidance_applied: { icon: 'fa-share', tone: 'success', kind: 'guidance', titleKey: 'timelineEventGuidanceApplied' },
user_guidance_discarded: { icon: 'fa-ban', tone: 'warn', kind: 'guidance', titleKey: 'timelineEventGuidanceDiscarded' },
model_completed: { icon: 'fa-quote-left', tone: 'info', kind: 'narration', titleKey: 'timelineEventNarration' },
run_completed: { icon: 'fa-circle-check', tone: 'success', kind: 'done', titleKey: 'timelineEventRunCompleted' },
run_partial_success: { icon: 'fa-circle-exclamation', tone: 'warn', kind: 'partial', titleKey: 'timelineEventRunPartialSuccess' },
@ -295,9 +301,31 @@ export function buildEventDetailTargets(item, allEvents) {
addFile('timelineWorkspaceFile', payload.path, event.payload);
}
if (event?.type === 'user_guidance_submitted'
|| event?.type === 'user_guidance_applied'
|| event?.type === 'user_guidance_discarded') {
targets.push(buildGuidanceDetailTarget(payload));
}
return targets;
}
function buildGuidanceDetailTarget(payload) {
return {
type: 'guidance',
labelKey: 'timelineGuidance',
guidanceIds: normalizeGuidanceIds(payload),
clientGuidanceIds: normalizeClientGuidanceIds(payload),
invocationId: payload.invocationId || '',
round: payload.round,
status: payload.status || '',
reason: payload.reason || '',
text: payload.text || '',
preview: payload.preview || '',
...textMetricFields(payload),
};
}
function buildPatchDiffTarget(event, events) {
const payload = plainObject(event?.payload) ? event.payload : {};
const path = String(payload.path || '').trim();
@ -621,6 +649,9 @@ function eventTitleParams(type, payload) {
return { count: payload.changeCount ?? 0 };
case 'drift_recovery_attempted':
return { attempt: payload.attempt ?? 0, max: payload.maxAttempts ?? 0 };
case 'user_guidance_applied':
case 'user_guidance_discarded':
return { count: payload.count ?? normalizeGuidanceIds(payload).length };
case 'run_partial_success':
return { count: payload.preservedCommitCount ?? 0 };
default:
@ -670,6 +701,11 @@ function eventSummary(type, payload, allEvents) {
return Array.isArray(payload.changes) ? payload.changes.map((change) => change.path).filter(Boolean).join(', ') : '';
case 'drift_recovery_attempted':
return payload.reasonCode || '';
case 'user_guidance_submitted':
case 'user_guidance_applied':
return guidanceSummary(payload);
case 'user_guidance_discarded':
return [payload.reason, guidanceSummary(payload)].filter(Boolean).join(' | ');
case 'run_cancelled':
return payload.message || '';
case 'run_partial_success':
@ -690,6 +726,9 @@ function eventKind(type, payload, fallback) {
if (type === 'agent_handoff_accepted') {
return 'handoff';
}
if (type.startsWith('user_guidance_')) {
return 'guidance';
}
if (type === 'tool_call_requested' || type === 'tool_call_completed') {
return toolKind(payload.name);
}
@ -754,6 +793,31 @@ function commitCompletedSummary(payload, events) {
return parts.filter(Boolean).join(' | ');
}
function guidanceSummary(payload) {
return String(payload.preview || '').trim()
|| textMetricsSummary(payload)
|| normalizeGuidanceIds(payload).join(', ');
}
function normalizeGuidanceIds(payload) {
const ids = normalizeStringArray(payload.guidanceIds);
const guidanceId = String(payload.guidanceId || '').trim();
return guidanceId ? [guidanceId, ...ids] : ids;
}
function normalizeClientGuidanceIds(payload) {
const ids = normalizeStringArray(payload.clientGuidanceIds);
const clientGuidanceId = String(payload.clientGuidanceId || '').trim();
return clientGuidanceId ? [clientGuidanceId, ...ids] : ids;
}
function normalizeStringArray(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function findCommitRequestedEvent(events, commitId) {
const normalized = String(commitId || '').trim();
if (!normalized) {

View file

@ -2,6 +2,7 @@ import { requireHostApi } from './host-api.js';
import { translateAgentSystem as tr } from './i18n.js';
import {
formatDetailFile,
formatGuidanceDetail,
formatHandoffDetail,
formatModelTurnDetail,
formatPatchDiffDetail,
@ -35,6 +36,9 @@ export async function readTimelineDetailTarget({ runId, target, readOnly = false
if (target.type === 'subAgentTask') {
return formatSubAgentTaskDetail(target);
}
if (target.type === 'guidance') {
return formatGuidanceDetail(target);
}
if (target.type === 'modelTurn' || target.type === 'modelReasoning' || target.type === 'modelNarration') {
const input = {
runId: normalizedRunId,

View file

@ -35,6 +35,8 @@ export function timelineItemShortLabel(item) {
return tr('timelineOpHandoff');
case 'subagent':
return tr('timelineOpSubAgent');
case 'guidance':
return tr('timelineOpGuidance');
default:
break;
}

View file

@ -8,6 +8,7 @@ const ROLLBACK_EVENT_TYPE = 'run_rollback_targets';
let activeRun = null;
let rollbackScriptOverride = null;
let guidanceSequence = 0;
function requireAgentApi() {
const agent = window.__TAURITAVERN__?.api?.agent;
@ -89,6 +90,19 @@ export async function cancelActiveAgentRun() {
return true;
}
export async function submitGuidanceToActiveAgentRun(text) {
const runId = String(activeRun?.runId || '').trim();
if (!runId) {
throw new Error('agent.guidance_active_run_missing: no active Agent run');
}
return requireAgentApi().submitGuidance({
runId,
text,
clientGuidanceId: createClientGuidanceId(),
});
}
export async function startAndWaitForAgentRun(input) {
if (activeRun?.runId) {
throw new Error(`Agent run ${activeRun.runId} is already active`);
@ -172,6 +186,16 @@ async function handleRollbackEvent(runId, event) {
await rollbackAgentRunDriftMessages({ runId, targets, script });
}
function createClientGuidanceId() {
const randomId = globalThis.crypto?.randomUUID?.();
if (randomId) {
return `client_guidance_${randomId}`;
}
guidanceSequence += 1;
return `client_guidance_${Date.now()}_${guidanceSequence}`;
}
export function subscribeAgentRunState(listener) {
const handler = (event) => listener(event.detail);
window.addEventListener(AGENT_RUN_STATE_CHANGED, handler);

View file

@ -4772,6 +4772,14 @@ body:is([data-generating="true"], [data-swiping="true"]) :is(#send_but,
display: none;
}
body[data-generating="true"][data-agent-guidance-ready="true"] #rightSendForm > #send_but {
display: flex !important;
}
body[data-generating="true"][data-agent-guidance-ready="true"] #mes_stop {
display: none !important;
}
#anchor_order {
margin-bottom: 15px;
}

View file

@ -0,0 +1,51 @@
// @ts-check
/**
* @param {{ safeInvoke: (command: string, args?: any) => Promise<any> }} deps
*/
export function createAgentRunGuidanceApi({ safeInvoke }) {
async function submitGuidance(input = {}) {
if (!isPlainObject(input)) {
throw new Error('Agent submitGuidance input must be an object');
}
const runId = requireRunId(input.runId);
const text = String(input.text || '').trim();
if (!text) {
throw new Error('guidance text is required');
}
const clientGuidanceId = normalizeOptionalString(input.clientGuidanceId ?? input.client_guidance_id);
return safeInvoke('submit_agent_run_guidance', {
dto: {
runId,
text,
...(clientGuidanceId ? { clientGuidanceId } : {}),
},
});
}
return {
submitGuidance,
};
}
function requireRunId(value) {
const runId = String(value || '').trim();
if (!runId) {
throw new Error('runId is required');
}
return runId;
}
function normalizeOptionalString(value) {
if (value == null || value === '') {
return undefined;
}
const text = String(value).trim();
return text || undefined;
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

View file

@ -1,7 +1,6 @@
// @ts-check
import { resolveStableChatId } from './agent-chat-identity.js';
import { createAgentRunGuidanceApi } from './agent-run-guidance.js';
const DEFAULT_EVENT_POLL_MS = 500;
const MAX_RUN_LIST_LIMIT = 200;
const MAX_RUN_PRUNE_DETAIL_LIMIT = 1000;
@ -27,6 +26,8 @@ const AGENT_RUN_STATUSES = new Set([
* @param {{ safeInvoke: (command: string, args?: any) => Promise<any> }} deps
*/
export function createAgentRunRuntimeApi({ safeInvoke }) {
const guidance = createAgentRunGuidanceApi({ safeInvoke });
async function cancel(runId) {
const normalizedRunId = requireRunId(runId);
return safeInvoke('cancel_agent_run', { dto: { runId: normalizedRunId } });
@ -247,6 +248,7 @@ export function createAgentRunRuntimeApi({ safeInvoke }) {
return {
cancel,
submitGuidance: guidance.submitGuidance,
listRuns,
retention: {
readSettings: readRetentionSettings,

View file

@ -85,6 +85,7 @@ function createAgentApi({ safeInvoke }) {
startRunWithPromptSnapshot,
startRunFromLegacyGenerate,
cancel: runtime.cancel,
submitGuidance: runtime.submitGuidance,
readEvents: runtime.readEvents,
readWorkspaceFile: runtime.readWorkspaceFile,
readModelTurn: runtime.readModelTurn,

16
src/types.d.ts vendored
View file

@ -167,6 +167,17 @@ type TauriTavernAgentRunHandle = {
status: TauriTavernAgentRunStatus;
};
type TauriTavernAgentGuidanceResult = {
runId: string;
guidanceId: string;
clientGuidanceId?: string;
status: 'queued';
preview: string;
chars: number;
words: number;
pendingCount: number;
};
type TauriTavernAgentRunListCursor = {
createdAt: string;
runId: string;
@ -525,6 +536,11 @@ type TauriTavernAgentApi = {
options?: { presentation?: TauriTavernAgentRunPresentation; stream?: false };
}) => Promise<TauriTavernAgentRunHandle>;
cancel: (runId: string) => Promise<TauriTavernAgentRunHandle>;
submitGuidance: (input: {
runId: string;
text: string;
clientGuidanceId?: string;
}) => Promise<TauriTavernAgentGuidanceResult>;
readEvents: (input: {
runId: string;
afterSeq?: number;

View file

@ -211,6 +211,55 @@ test('api.agent.readEvents requests timeline projection only when asked', async
]);
});
test('api.agent.submitGuidance forwards camelCase DTO and fails fast on invalid input', async () => {
const { calls, agent } = await installHarness();
await agent.submitGuidance({
runId: ' run_guidance ',
text: ' Keep the ending restrained. ',
clientGuidanceId: ' client-guidance-1 ',
});
await agent.submitGuidance({
runId: 'run_guidance',
text: 'No client id.',
});
assert.deepEqual(calls, [
{
command: 'submit_agent_run_guidance',
args: {
dto: {
runId: 'run_guidance',
text: 'Keep the ending restrained.',
clientGuidanceId: 'client-guidance-1',
},
},
},
{
command: 'submit_agent_run_guidance',
args: {
dto: {
runId: 'run_guidance',
text: 'No client id.',
},
},
},
]);
await assert.rejects(
() => agent.submitGuidance(null),
/Agent submitGuidance input must be an object/,
);
await assert.rejects(
() => agent.submitGuidance({ runId: '', text: 'hello' }),
/runId is required/,
);
await assert.rejects(
() => agent.submitGuidance({ runId: 'run_guidance', text: ' ' }),
/guidance text is required/,
);
});
test('api.agent.listRuns forwards run history filters with camelCase DTOs', async () => {
const { calls, agent } = await installHarness();
const chatRef = { kind: 'character', characterId: 'char-1', fileName: 'Char.json' };

View file

@ -615,6 +615,92 @@ test('Agent run timeline refresh predicates use narrated model turns explicitly'
assert.doesNotMatch(dialogSource, /event\.type === 'model_completed'/);
});
test('Agent run timeline displays user guidance events with inline detail targets', async () => {
const presenter = await importFresh('src/scripts/extensions/agent-system/src/run-event-presenter.js');
const detailFormat = await importFresh('src/scripts/extensions/agent-system/src/run-detail-format.js');
const events = [
{
seq: 1,
id: 'evt-guidance-submitted',
runId: 'run-1',
type: 'user_guidance_submitted',
payload: {
guidanceId: 'guidance_1',
clientGuidanceId: 'client_1',
invocationId: 'inv_root',
status: 'queued',
text: 'Keep the next step focused on the revised ending.',
preview: 'Keep the next step focused on the revised ending.',
chars: 52,
words: 9,
},
},
{
seq: 2,
id: 'evt-guidance-applied',
runId: 'run-1',
type: 'user_guidance_applied',
payload: {
guidanceIds: ['guidance_1'],
clientGuidanceIds: ['client_1'],
invocationId: 'inv_root',
round: 2,
count: 1,
status: 'applied',
preview: 'Keep the next step focused on the revised ending.',
chars: 52,
words: 9,
},
},
{
seq: 3,
id: 'evt-guidance-discarded',
runId: 'run-1',
type: 'user_guidance_discarded',
payload: {
guidanceIds: ['guidance_2'],
clientGuidanceIds: [],
invocationId: 'inv_root',
count: 1,
status: 'discarded',
reason: 'run_cancelled',
preview: 'Ignore the previous outline.',
chars: 28,
words: 4,
},
},
];
const items = presenter.timelineItemsFromEvents(events);
assert.deepEqual(items.map(item => item.type), [
'user_guidance_submitted',
'user_guidance_applied',
'user_guidance_discarded',
]);
assert.deepEqual(items.map(item => item.kind), ['guidance', 'guidance', 'guidance']);
assert.deepEqual(items.map(item => item.titleKey), [
'timelineEventGuidanceSubmitted',
'timelineEventGuidanceApplied',
'timelineEventGuidanceDiscarded',
]);
assert.equal(items[0].summary, 'Keep the next step focused on the revised ending.');
assert.equal(items[1].titleParams.count, 1);
assert.equal(items[2].summary, 'run_cancelled | Ignore the previous outline.');
const targets = presenter.buildEventDetailTargets(items[0], events);
assert.equal(targets.length, 1);
assert.equal(targets[0].type, 'guidance');
assert.deepEqual(targets[0].guidanceIds, ['guidance_1']);
assert.deepEqual(targets[0].clientGuidanceIds, ['client_1']);
assert.equal(targets[0].text, 'Keep the next step focused on the revised ending.');
const section = detailFormat.formatGuidanceDetail(targets[0]);
assert.equal(section.labelKey, 'timelineGuidance');
assert.equal(section.blocks[0].kind, 'user');
assert.equal(section.blocks[0].text, 'Keep the next step focused on the revised ending.');
assert.ok(section.fields.some(field => field.value === 'guidance_1'));
});
test('Agent run timeline keeps SubAgent dialog state outside the main panel', async () => {
const panelSource = await readFile(path.join(
REPO_ROOT,
@ -2802,6 +2888,85 @@ test('Agent run controller tracks active runs until terminal events', async () =
assert.equal(stateChanges.at(-1).lastEvent.type, 'run_completed');
});
test('Agent run controller submits guidance through the active run facade', async () => {
let listener = null;
const submissions = [];
installWindow({
agent: {
async startRunWithPromptSnapshot(input) {
return { runId: 'run-guidance', input };
},
subscribe(runId, callback) {
assert.equal(runId, 'run-guidance');
listener = callback;
return () => {};
},
async submitGuidance(input) {
submissions.push(input);
return {
runId: input.runId,
guidanceId: 'guidance_1',
status: 'queued',
preview: input.text,
chars: input.text.length,
words: 1,
pendingCount: 1,
};
},
},
});
const controller = await importFresh('src/scripts/tauritavern/agent/agent-run-controller.js');
const run = controller.startAndWaitForAgentRun({ generationType: 'normal' });
await Promise.resolve();
assert.equal(controller.hasActiveAgentRun(), true);
await controller.submitGuidanceToActiveAgentRun('Steer the next step.');
assert.equal(submissions.length, 1);
assert.equal(submissions[0].runId, 'run-guidance');
assert.equal(submissions[0].text, 'Steer the next step.');
assert.match(submissions[0].clientGuidanceId, /^client_guidance_/);
listener({ type: 'run_completed', payload: {} });
await run;
});
test('Agent guidance composer intercepts active-run sends before busy generation gates', async () => {
const scriptSource = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
const styleSource = await readFile(path.join(REPO_ROOT, 'src/style.css'), 'utf8');
const sendStart = scriptSource.indexOf('export async function sendTextareaMessage() {');
assert.ok(sendStart >= 0, 'sendTextareaMessage must exist');
const sendGateEnd = scriptSource.indexOf('hideSwipeButtons();', sendStart);
assert.ok(sendGateEnd > sendStart, 'sendTextareaMessage gate block must be present');
const sendGateSource = scriptSource.slice(sendStart, sendGateEnd);
const guidanceGateIndex = sendGateSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()');
const sendPressIndex = sendGateSource.indexOf('if (is_send_press) return;');
assert.ok(guidanceGateIndex >= 0, 'Agent guidance gate must run from sendTextareaMessage');
assert.ok(sendPressIndex >= 0, 'is_send_press gate must stay explicit');
assert.ok(guidanceGateIndex < sendPressIndex, 'guidance must be offered before active generation blocks sends');
const clickSource = sourceBetween(
scriptSource,
'const userInputGenerateMutex = new SimpleMutex(sendTextareaMessage);',
' //menu buttons setup',
);
assert.ok(clickSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()') >= 0);
assert.ok(clickSource.indexOf('await maybeSubmitAgentGuidanceFromComposer()') < clickSource.indexOf('userInputGenerateMutex.update()'));
const guidanceOfferSource = sourceBetween(
scriptSource,
'function shouldOfferAgentGuidanceFromComposer() {',
'function syncAgentGuidanceComposerState() {',
);
assert.match(guidanceOfferSource, /hasActiveAgentRun\(\)/);
assert.match(guidanceOfferSource, /getAgentGuidanceComposerText\(\)/);
assert.doesNotMatch(guidanceOfferSource, /swipeState|isExecutingCommandsFromChatInput|regenerate/);
assert.match(scriptSource, /subscribeAgentRunState\(syncAgentGuidanceComposerState\)/);
assert.match(scriptSource, /Popup\.show\.confirm\(\s*'是否引导Agent行为?'/);
assert.match(styleSource, /body\[data-generating="true"\]\[data-agent-guidance-ready="true"\] #rightSendForm > #send_but\s*\{\s*display:\s*flex !important;/);
assert.match(styleSource, /body\[data-generating="true"\]\[data-agent-guidance-ready="true"\] #mes_stop/);
});
test('Agent run controller treats partial success as a terminal resolved run', async () => {
let listener = null;
installWindow({