Macro Agent Harness 队列编辑:EditQueuedActionData 类型与 editQueuedAction 接口全解析

发布时间:2026/11/1 1:38:03
Macro Agent Harness 队列编辑:EditQueuedActionData 类型与 editQueuedAction 接口全解析
  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

在 Macro 的 Agent 会话体系中,当一条 prompt 或 compact 在某个 turn 运行期间被提交时,它不会立刻执行,而是进入会话的服务端队列等待该 turn 结束。EditQueuedActionData正是描述"在派发之前改写这条排队 prompt"这一操作的请求类型别名——它把 HTTP 方法、路径模板、路径参数与请求体绑定在同一个类型里。本文以 SDK 类型文档为骨架,结合agent_session路由层、agent_harness队列实现与 SDK 封装,完整讲清这个类型每个字段的含义、服务端的真实行为边界以及可复制的调用方式。

类型定义:一个请求的完整形状

类型文档给出的定义非常紧凑:

type EditQueuedActionData = { body: EditQueuedActionRequest; path: { action_id: string; session_id: string }; query?: never; url: "/agent-sessions/{session_id}/queue/{action_id}"; }

对应源文档:EditQueuedActionData。

这个形状是 orval 从 OpenAPI 规范生成的"请求参数聚合类型":它不是响应体,也不是领域模型,而是对一次 HTTP 调用的全部输入的静态描述。理解它的关键在于四个字段的职责划分。

字段类型必填含义
bodyEditQueuedActionRequest是请求体,仅包含新的 prompt 文本
path.action_idstring是该 action 被受理时获得的 ID
path.session_idstring是所属 agent session 的 ID
query?never否显式声明该接口没有查询参数(never表示不可传任何 query)
url"/agent-sessions/{session_id}/queue/{action_id}"—路径模板,两个占位符由path填充

query?: never是一个值得注意的生成细节:它不是"可选参数为空",而是类型层面禁止任何查询参数,配合url字符串一起,让调用方在编译期就锁死了请求形态。url字面量同时承担了"这是 PUT 到资源地址"的语义提示——虽然在类型里没有出现 HTTP 方法,方法由 SDK 生成代码固定。

两个路径参数在源文档中分别带有说明:action_id是 "ID the action was accepted under"(该 action 被受理时所用的 ID),session_id是 "ID of the agent session"。注意前者不是消息 ID,也不是 turn ID;只有当 action 真正派发后,这个 ID 才会等价于折叠消息上的requestId——这一点在 SDK 的高层封装注释中有明确表述。

请求体:为什么不接受空白字符串

EditQueuedActionData的body指向 EditQueuedActionRequest,其定义为:

type EditQueuedActionRequest = { /** The new raw prompt text, replacing the old wholesale. Never blank */ prompt: string; }

前端生成的接口定义 editQueuedActionRequest.ts 进一步给出了约束:

export interface EditQueuedActionRequest { /** * The new raw prompt text, replacing the old wholesale. Never blank: a * prompt with nothing to say is a removal, and there is an endpoint for * that. * @minLength 1 */ prompt: string; }

三个语义要点:

  1. 整体替换(replace wholesale):不是追加、不是 diff 合并。传入的prompt会完整取代队列条目的原文本。
  2. 不接受空白:@minLength 1只是第一道防线,服务端还会对trim()之后的空串做二次判断,直接拒绝。
  3. "想清空就等于想删除":文档注释直白地指出,把 prompt 改成空内容在语义上是"移除",而移除有专门的接口(DELETE同一路径,对应 RemoveQueuedActionData)。

换而言之,EditQueuedActionData只描述"改写",而不描述"删除"或"插队"。删除与插队分别由同一路径的DELETE和.../steer端点承担,三者共享同一组路径参数。

HTTP 契约:方法、路径与状态码

路由注册在 crates/agent_session/src/inbound/axum_router.rs:

.route( "/{session_id}/queue", get(get_agent_session_queue_handler::<R, Access, Auth>), ) .route( "/{session_id}/queue/{action_id}", put(edit_queued_action_handler::<R, Access, Auth>) .delete(remove_queued_action_handler::<R, Access, Auth>), ) .route( "/{session_id}/queue/{action_id}/steer", post(steer_queued_action_handler::<R, Access, Auth>), )

可以看到PUT /{session_id}/queue/{action_id}与DELETE共用同一个路径,这正是 REST 语义下的"修改"与"删除";steer则在末尾多出一段子路径。

OpenAPI 处理器 编辑接口定义 声明了响应集合,落在类型层就是 EditQueuedActionResponses 与 EditQueuedActionErrors。编译期可见的错误集合为{ 401: string; 403: string; 404: string; 422: string; 500: string }。

状态码来源错误触发条件
204 No Content无编辑成功,响应体为空
401鉴权失败调用方未通过 Macro 授权校验
403Forbidden无编辑权限;或 Harness 调用方并非该会话的托管者;或沙箱编码会话的非员工操作
404QueuedControlNotFound该 action 已派发、已被移除,或从未入队
422EmptyQueuedPrompt/QueuedControlNotEditable新文本 trim 后为空;或目标条目本身不携带文本(如compact)
500其他领域/内部错误持久化失败等

成功分支返回StatusCode::NO_CONTENT,这解释了为什么类型是204: void——调用方拿不到更新后的对象,只能依赖本地状态或重新拉取队列。

一个值得记录的实现细节:错误映射表把Archived映射为409 CONFLICT(错误映射),但409并未出现在EditQueuedActionErrors的生成结果中。从源码结构看,这是生成类型与运行时行为之间的一处缝隙,集成方在编写兜底分支时应当把未列举的 4xx/5xx 也纳入通用错误处理。

服务端处理链路:从 handler 到队列

EditQueuedActionData的字段最终会在 handler 里被逐一对上。核心实现在 edit_queued_action_handler:

pub async fn edit_queued_action_handler<R, Access, Auth>( _access: AgentSessionAccessLevelExtractor<EditAccessLevel, Access, Auth>, State(state): State<AgentSessionControlState<R, Access, Auth>>, caller: MacroAuthorizationExtractor<Auth, UserBotOrHarness>, Path((session_id, action_id)): Path<(Uuid, Uuid)>, Json(request): Json<EditQueuedActionRequest>, ) -> Result<StatusCode, AgentSessionApiError> { let session_id = AgentSessionId::new_from_uuid(session_id); ensure_harness_serves_session(&caller.authorization, state.recipient.as_ref(), session_id) .await?; if request.prompt.trim().is_empty() { return Err(AgentSessionError::EmptyQueuedPrompt.into()); } ... }

可以把这段代码与类型字段一一对应:

  • path.session_id、path.action_id→Path((session_id, action_id)): Path<(Uuid, Uuid)>。两个参数在类型层是string,落到服务端被解析为Uuid,因此非法 UUID 会在反序列化阶段就失败。
  • body→Json(request): Json<EditQueuedActionRequest>。
  • query?: never→ handler 签名里根本没有Query<...>提取器,协议层面无从携带查询参数。

几个容易被忽略的守卫:

  1. 权限级别是编辑级:AgentSessionAccessLevelExtractor<EditAccessLevel, ...>。读取队列只需要查看权限,而改写需要编辑权限——"能看"不等于"能改"。
  2. Harness 只能操作自己服务的会话:ensure_harness_serves_session 会比对recipient.session_harness(session_id)与调用方自身的harness_id。仅凭"代表某个用户"不足以驱动别人托管的会话。
  3. 空白文本提前拒绝:prompt.trim().is_empty()直接返回EmptyQueuedPrompt,对应422。

随后 handler 从授权信息里取出"实际操作人"并下发给接收方:

let actor = caller.authorization.acting_user() .map(|user| user.macro_user_id.clone()); state.recipient.edit_queued_control( session_id, AgentActionId::from_uuid(action_id), request.prompt, actor, ).await?;

注意actor是Option:用户直连时是Some,由 bot 代表"无人"操作时可能是None。这个字段会一路传到队列条目上,并成为派发时被记录的操作人。

端口层:能力声明

edit_queued_control在端口 trait 中的文档注释(crates/agent_session/src/domain/ports.rs)把行为边界写得很明确:

  • 已派发 →QueuedControlNotFound;
  • 不携带文本的排队动作 →QueuedControlNotEditable;
  • actor的判定与"发送"使用同一套门禁:"谁不能给会话发 prompt,谁就不能改写它即将被喂进去的内容"。

这解释了为什么编辑接口复用EditAccessLevel:编辑队列条目在权限模型上等价于发送 prompt,而不是等价于修改一条历史消息。

Harness 层:命令化与持久化

agent_harness侧把编辑包装成一条HarnessCommand::EditQueued命令(crates/agent_harness/src/domain/service/lifecycle.rs),确保同一会话的所有变更都在会话自己的命令通道上串行执行。真正的落地在 queue.rs:

HarnessCommand::EditQueued { action_id, prompt, actor } => { queue_result( self.queues.edit_prompt(session_id, action_id, prompt, actor), session_id, )?; self.persist_or_rollback(session_id).await?; self.publish_queue(session_id).await; Ok(CommandOutcome::Completed) }

三步走:改内存 → 落盘(失败则回滚重载)→ 广播队列变更。persist_or_rollback的实现(queue.rs)在写库失败时会尝试reload_queue,然后返回错误,避免内存与持久层出现分叉;publish_queue则发出AgentSessionQueueChanged事件,让订阅端刷新队列快照。

内存队列的编辑语义在 edit_prompt 中:

pub fn edit_prompt( &self, session: AgentSessionId, action_id: AgentActionId, prompt: String, actor: Option<MacroUserIdStr<'static>>, ) -> Result<(), QueueError> { let mut queue = self.queues.get_mut(&session).ok_or(QueueError::NotFound)?; let entry = queue .iter_mut() .find(|entry| entry.action_id == action_id) .ok_or(QueueError::NotFound)?; match &mut entry.action { AgentAction::Prompt(action) => { action.prompt = prompt; entry.actor = actor; Ok(()) } AgentAction::SetModel(_) | AgentAction::SetConfigOption(_) | AgentAction::Compact | AgentAction::Stop | AgentAction::RespondElicitation(_) | AgentAction::RespondToPermission(_) => Err(QueueError::NotEditable), } }

由此可以确认四条确定性行为:

  • 原地更新:条目"保留位置与 ID"(文档注释原文为 "The entry keeps its place and its id"),编辑不会改变它在队列中的顺序,也不会生成新的action_id。
  • 只对prompt生效:Compact、SetModel、SetConfigOption、Stop、权限回应、elicitation 回应全部返回NotEditable,映射为422。这也解释了QueuedActionDto.kind的说明——只有"占用 turn 的动作"才会入队,而其中只有prompt携带可改写文本。
  • 操作人被替换为编辑者:entry.actor = actor。注释写得很直接:改写文本的人才是派发时被播报与记日志的人,不能让别人替他背下他没写过的字。
  • 附件不受影响:正文替换只作用于文本,DTO 中attachments的注释明确写着 "Kept through an edit, which replaces the text alone"。

门禁前置检查

在命令真正执行前,queue.rs 的分派前置检查还会做两件事:

HarnessCommand::Deliver(DeliverAction { actor, .. }) | HarnessCommand::EditQueued { actor, .. } | HarnessCommand::RemoveQueued { actor, .. } | HarnessCommand::SteerQueued { actor, .. } => { let session = self.sessions.get_session(session_id).await?; if session.is_archived { return Err(AgentSessionError::Archived(session_id).into()); } if AgentKind::of(session.bot_id) == AgentKind::SandboxedCoder && !actor.as_ref().is_some_and(is_macro_staff) { return Err(AgentSessionError::Forbidden.into()); } }

即:归档会话不可编辑;沙箱编码类会话的队列改写仅限员工。注释解释了原因——被编辑的条目稍后会以原身份派发,改写 Daytona 会话即将执行的内容,与直接给它发 prompt 是同一级别的权限。

另外,队列本身有容量上限QUEUE_CAP = 50(queue.rs),满队列返回ControlQueueFull,同样映射为422。编辑已有条目不会增加条目数,因此不触及该上限;但在设计批量入队逻辑时应当把它考虑进去。

SDK 调用:从生成客户端到高层封装

底层:生成的editQueuedAction

EditQueuedActionData直接对应生成 SDK 中的方法(packages/sdk/generated/agent-harness/sdk.gen.ts):

public editQueuedAction<ThrowOnError extends boolean = false>( options: Options<EditQueuedActionData, ThrowOnError>, ): RequestResult<EditQueuedActionResponses, EditQueuedActionErrors, ThrowOnError> { return (options.client ?? this.client).put< EditQueuedActionResponses, EditQueuedActionErrors, ThrowOnError >({ url: '/agent-sessions/{session_id}/queue/{action_id}', ...options, headers: { 'Content-Type': 'application/json', ...options.headers } }); }

三点可从代码直接读出:

  • 方法固定为put,与url模板共同构成完整调用;
  • 自动带上Content-Type: application/json;
  • 返回类型把响应与错误集合都参数化,配合ThrowOnError决定是抛异常还是把错误放进返回值。

因为响应是204,EditQueuedActionResponse的类型定义为EditQueuedActionResponses[keyof EditQueuedActionResponses],即void;EditQueuedActionError同理是错误集合的值联合类型。

高层:QueuedAction.edit()

手写 path 参数容易出错,SDK 提供了面向对象封装 queued-action.ts:

async edit(prompt: string): Promise<this> { unwrap( await this.client.agentHarness.editQueuedAction({ path: { session_id: this.sessionId, action_id: this.actionId }, body: { prompt }, }), ); this.dto = { ...this.dto, prompt }; return this; }

这段实现有两个设计点值得学习:

  1. 本地回填:由于服务端返回204,没有响应体可读,封装直接把新prompt合入本地 DTO,使action.prompt立即反映最新值。
  2. 可链式:返回this,允许await action.edit("...").then(a => a.steer())这类连续操作。

QueuedAction的获取方式只有一种——从队列快照构造。类注释说明了原因:没有单条 action 的GET,只有队列列表接口,因此它采用 "wrap whatever DTO the caller already has" 的模式(与CrmComment包装列表记录同构),而不是按 ID 懒加载。对应的高层入口是 AgentSession.queue():

async queue(): Promise<QueuedAction[]> { const { entries } = unwrap( await this.client.agentHarness.getAgentSessionQueue({ path: { session_id: this.id }, }), ); return entries.map((entry) => QueuedAction.from(this.client, this.id, entry)); }

端到端示例:排队、改写、插队与移除

下面这段示例仅使用仓库中已确认存在的 SDK 入口,展示一次完整的"排队 → 编辑 → 插队 / 移除"流程。它依赖MacroClient中挂载的agentHarness客户端与AgentSession实体。

import type { MacroClient } from './packages/sdk/src/utils/client'; import type { AgentSession } from './packages/sdk/src/entities/agent-sessions/agent-session'; // session: 已加载的 AgentSession export async function editWhileRunning( client: MacroClient, session: AgentSession, ) { // 1. 在一个 turn 运行中提交:会被排进服务端队列 const accepted = await session.prompt('请把刚才的重构补上单元测试'); // accepted.status 为 'queued' 时,action_id 就是后续编辑所需的 id // 2. 读取队列快照,包装成可操作句柄 const queued = await session.queue(); // 3. 找到目标条目:kind === 'prompt' 才可编辑 const target = queued.find((a) => a.actionId === accepted.action_id); if (!target) return; // 已派发或已被移除 // 4. 整体替换文本(附件保留,位置与 id 不变) await target.edit('请把刚才的重构补上单元测试,并覆盖空输入分支'); // 5. 可选:插队到队首并打断当前 turn,或直接移除 // await target.steer(); // await target.remove(); }

几个与类型定义强相关的注意点:

  • accepted.action_id来自控制接口的返回,它是"被受理时的 ID",也是path.action_id的唯一正确来源;不要用消息 ID 或本地生成的 UUID 替代。
  • 若target.kind === 'compact',编辑会稳定得到422 QueuedControlNotEditable,应在客户端提前分支处理。
  • 若条目已经派发,find会失败(服务端也会返回404)。这属于正常的竞态,不应视为异常路径。

与相邻操作的对比:编辑、移除、插队

同一路径族下的三个操作在类型与语义上形成互补关系:

操作类型别名方法/路径是否携带 body关键效果
编辑EditQueuedActionDataPUT /agent-sessions/{session_id}/queue/{action_id}是,{ prompt }原地替换文本,保留位置与 ID
移除RemoveQueuedActionDataDELETE同路径否,body?: never派发前彻底删除
插队SteerQueuedActionDataPOST .../{action_id}/steer否,body?: never移到队首并在当前 turn 结束时优先派发

三者的query都是never,path结构完全一致,只有body与url尾部不同。这种严格的对称性让代码生成与前端封装都能保持统一。

steer的额外语义来自 端口文档与路由注释:它会把条目前移,并在有 turn 在飞时取消该 turn,使这条动作在"当前 turn 结束"后立刻派发。相比之下,edit绝不影响调度顺序——它只改字,不排队。

测试证据:行为不是推测

队列编辑的核心承诺在集成测试中被逐条钉死。crates/agent_harness/src/domain/service/test.rs 中的queued_prompts_are_editable_and_removable_until_dispatch覆盖了完整生命周期:

service.edit_queued_control( id, second.action_id, "second, rewritten".to_owned(), Some(staff_sender()), ).await .expect("a waiting prompt is editable"); assert_eq!( rewritten.actor.as_ref(), Some(&staff_sender()), "an edit is attributed to the editor, not the original queuer" );

断言"the edit lands because dispatch is where the text is read"进一步说明了一个重要机制:文本是在派发时刻才被读取的,所以排队期间的编辑一定生效,不存在"写进某个快照后再也改不动"的问题。

测试后半段验证了"派发即消失":

let error = service .remove_queued_control(id, second.action_id, Some(sender())) .await .expect_err("a dispatched prompt is not removable"); assert!(matches!(error, AgentSessionError::QueuedControlNotFound));

对已派发条目做移除或编辑都得到QueuedControlNotFound,最终在 HTTP 层表现为404——这与EditQueuedActionErrors中的404说明 "Already dispatched or never queued" 完全对应。

工程实践建议

结合类型定义与服务端实现,落地时可以遵循以下判断:

  1. 不要用编辑实现"清空":prompt.trim()为空会被拒为422,正确做法是调用remove()。类型层已经通过注释把这条规则写进了文档。
  2. 编辑前先确认kind:compact与各类控制动作不可编辑。用QueuedActionDto.kind或高层封装的kindgetter 做前置判断,可以避免无意义的往返。
  3. 404是常态而非故障:turn 结束时队列会自然派发,前端拿着旧快照做编辑必然可能撞上404。建议把它当作"条目已达标"的幂等信号,刷新队列而不是弹错误。
  4. 失败后重新同步:服务端在持久化失败时会回滚内存并从存储重载,此时客户端应当重新session.queue()获取权威快照,而不是相信本地的乐观更新。
  5. 权限口径按"发送"对待:编辑使用EditAccessLevel,并且对沙箱编码会话额外要求员工身份。为外部提供能力时不要给出比发送 prompt 更宽的授权。
  6. 注意409未在类型中列举:归档会话会触发409 CONFLICT,但生成错误集合里没有它。兜底分支应覆盖未知 4xx/5xx。

小结

EditQueuedActionData看似只是一个自动生成的类型别名,但它把三层事实压缩在四个字段里:url与path锁定了"对某个会话队列中某条动作的原地修改",body明确了"整体替换、不可为空、只管文本",query?: never则宣告这个接口不接受任何过滤或开关参数。服务端链路进一步验证了这些语义——EditAccessLevel权限、Harness 托管校验、空白拒绝、只对prompt生效、操作人易主、附件保留、位置与 ID 不变、派发即失联(404)。

相关阅读可从类型文档本身出发,配合 EditQueuedActionRequest、QueuedActionDto 与 QueuedAction 类文档 形成完整认知,再对照 axum_router.rs、queue.rs 与 queued-action.ts 查看每一层实现。

  • 后端
  • 前端
  • 协同办公
  • 即时通讯
  • 企业应用
  • CRM
  • AI Agent
  • 人工智能

【免费下载链接】macro

Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.

项目地址:https://gitcode.com/GitHub_Trending/macro3/macro
点击查看免费下载

相关推荐

上一篇:WarcraftHelper终极指南:三步解决魔兽争霸3所有兼容性问题
下一篇:城通网盘解析工具终极指南:3步获取高速直连下载链接

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考