mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat(agent): add in-run user guidance steering
This commit is contained in:
parent
c7615089ae
commit
c62d23deb0
30 changed files with 1344 additions and 5 deletions
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ mod commit_ledger;
|
|||
mod delegation;
|
||||
mod error_payload;
|
||||
mod executor;
|
||||
mod guidance;
|
||||
mod input_context;
|
||||
mod invocation;
|
||||
mod journal;
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "已取消",
|
||||
|
|
|
|||
|
|
@ -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": "已取消",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export function timelineItemShortLabel(item) {
|
|||
return tr('timelineOpHandoff');
|
||||
case 'subagent':
|
||||
return tr('timelineOpSubAgent');
|
||||
case 'guidance':
|
||||
return tr('timelineOpGuidance');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
51
src/tauri/main/api/agent-run-guidance.js
Normal file
51
src/tauri/main/api/agent-run-guidance.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
16
src/types.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue