← 返回文章

ARTICLE

Agenstro 开发记录(二):架构设计

介绍 Agenstro 的 Clef、Tactus 与 Motivo 三层架构,以及工作流组合、provider 与副作用插件、运行监督和 human-in-the-loop 设计。

18 分钟阅读#Agenstro · #Haskell · #Rust · #工作流架构
文章目录

层级设计

由于要把codex,claudecode以及opencode这些作为工作流调用的最小单元,一个自然的问题是当这些llm公司修改了接口、调用方法怎么办。 为了让这个项目能够长久的更新下去,且适应llm日新月异的更新,第一步就是寻找一种让架构解耦的方法。

本项目采用了三层设计方法,如下所示:

  • 第一层,抽象层,task和workflow的核心抽象,给provider(此处指codex、claudecode、opencode等)和副作用控制plugin提供一个几乎固定不变的接口。
  • 第二层,Runtime层,负责具体插件的运行、监控、异常处理等。
  • 第三层,交互层,给出具体操纵该项目的方法

命名上笔者借鉴了古典音乐的命名方式,项目名称的Agenstro是Agent+Maestro(指挥家)的合并缩写;抽象层名字是clef,意思为谱记法;runtime层名称为tactus,意思为节奏、节拍;交互层名称为motivo,意思为动机(古典音乐中,动机是指用来引出主/副旋律的一小段乐句)。 其实我还独立于这三这准备了一个基于事物队列的持久化运行层,segno,他的含义是记谱中的重复段落,以为重复演奏。 不过这个小的模组现在还没有完全写完

下面简单的说说这三个层次的详细设计,以及他们的语言选择

Clef, 核心抽象层

Clef有两个核心理念:

  1. 可组合工作流:把一次agent loop封装为输入输出类型的计算,用顺序、分支和并行组合计算。
  2. provider接口和副作用控制插件接口。 前者负责接入具体agent,后者负责外部操作、状态观察。两类调用都进入同一个工作流模型,具体实现可以分别替换。

核心采用Haskell,是为了直接使用它的类型系统、函数组合和并发库,让编译器检查数据如何衔接,而不必另造一套工作流语言。

这样做的坏处自然是haskell的可读性对于大部分使用者而言略显复杂,在面向大众的过程中可能遇到阻碍。 理论上用Typescript也能有一样的效果(类似dsh那样,本身dsh的类型系统也很适合用haskell写),不过作为实验项目暂时这样弄。 实际笔者的haskell也写得很烂,对于此我采用的方法是干脆让llm自己来写haskell,我去审阅,这部分我们会在motivo模块再详细谈谈。

可组合的Workflow

先看整个程序的承载类型:

newtype Workflow a = Workflow
  { executeWorkflow :: Runtime -> IO a
  }

Workflow a表示一个成功执行后产生a的计算。它内部封装了一个函数:接收运行环境Runtime,返回带有外部操作的IO a。运行环境提供配置、调用限制和记录设施。 构造一个Workflow时还没有执行任务,运行入口提供环境后,计算才实际开始。

agent任务通过Task定义。它是带两个类型参数的记录类型,主要结构是:

data Task input output = Task
  { taskName     :: Text
  , renderPrompt :: input -> Text
  , decodeResult :: Text -> Either Text output
  }

input、output分别代表业务输入、输出类型。 记录里保存三样东西:任务名称、把输入转成提示词的函数,以及把最终回答解码为输出的函数。 Text表示文本; Either Text output表示解码可能返回错误文本Left,也可能返回成功值Right。 因此,Task是可复用的任务定义。

Either 是 haskell中的一个monad,有关monad的知识可以参考Richard Bird的《Thinking Functionally with Haskell》(Cambridge University Press,平装版ISBN:978-1-107-45264-0); 不必郁结于monad这个名称,只把他当作一种异常处理的方式即可。

以“生成方案,再审阅方案”为例,业务数据可以定义为:

data Plan = Plan
  { summary :: Text
  , files   :: [FilePath]
  }

data Review = Review
  { approved :: Bool
  , notes    :: Text
  }

Plan包含方案摘要和文件列表,Review包含是否通过及理由。 它们是业务数据类型,不由Clef预先规定。 data Plan = Plan ...左边的Plan是类型名,右边的Plan是构造该类型值的方法。

一个最简单实例是使用jsonTask根据输出类型完成JSON解码:

planTask :: Task Text Plan
planTask = jsonTask "plan" $ \request ->
  "根据需求制定方案,返回 JSON:summary 为摘要,files 为文件列表。需求:"
    <> request

reviewTask :: Task Plan Review
reviewTask = jsonTask "review" $ \candidate ->
  "审阅方案摘要,返回 JSON:approved 表示是否通过,notes 为理由。摘要:"
    <> summary candidate

planTask约定从需求文本得到方案,reviewTask约定从方案得到审阅意见。 \request -> ...是匿名函数,<>用于拼接文本。这里保存的是生成提示词的方法,调用时才代入具体输入。

invoke把任务定义和输入转成工作流计算:

invoke :: Task input output -> input -> Workflow output

plan :: Text -> Workflow Plan
plan = invoke planTask

review :: Plan -> Workflow Review
review = invoke reviewTask

只给invoke第一个参数planTask,就得到一个等待需求文本的函数plan,这就是领域函数。 这里要区分四个对象:planTask是任务定义,plan是函数,Plan是结果类型,plan request则是产生该结果的Workflow Plan计算。

接下来就可以组合它们:

planAndReview :: Text -> Workflow (Plan, Review)
planAndReview request = do
  candidate <- plan request
  critique <- review candidate
  pure (candidate, critique)

运行时,<-将前一步的成功值绑定到变量,再交给下一步; pure把普通值作为工作流结果返回。组合后仍然得到Workflow,因此这段流程还可以整体接入另一个流程。 实际脚本通过runTactus (planAndReview request)运行它。

这种顺序组合使用Workflow的Monad绑定,两步共享运行环境,后一步可以依据前一步的结果选择分支。 纯粹的数据转换可以用fmap,例如fmap summary (plan request)得到Workflow Text; 互不依赖的计算则显式使用:

parallel :: Workflow a -> Workflow b -> Workflow (a, b)

parallel并发执行两个计算,成功后汇合结果;一方抛出异常时,会取消尚未完成的另一方。顺序、分支和并行都在代码中表达,不需要先生成整张任务图。

编译器检查步骤之间的类型关系,例如不能把需求文本直接交给接收Plan的review。 模型回答能否解码为Plan,则由运行时检查。这两种检查分别处理程序连接错误和外部数据错误。

这样就能基本上满足一个DAG任务流的编写,并且一般不容易出现并发问题。

注释:框架本身无法保证写出一个足够好的workflow,框架只是有利于人类专家/llm集中精力到撰写工作流上,并保证写出来的工作流随着llm的演进只需要最低限度的修改,并且保证只要大部分错误能够在运行前(编译阶段)被发现。 这也是使用haskell的第二个理由:强大的类型系统和编译检查,这带来的另一个优势是human in the loop的便利,将会在motivo部分再做解释。

副作用控制插件接口和provider接口

工作流需要调用外部世界:agent可能读写文件、执行命令,程序也可能查询状态、运行实验。 Clef将这些能力接到插件上,并按用途区分两类接口。 provider负责执行agent任务,effect插件负责具体的外部操作及其观察或约束; provider调用本身也可能产生副作用。

Provider接口:把任务交给哪个agent执行。

Task保存提示词生成和结果解码规则,provider的选择由运行配置或调用位置给出。 invoke使用默认provider,需要明确指定时使用:

invokeWith :: ProviderRef -> Task input output -> input -> Workflow output

planWithCodex :: Text -> Workflow Plan
planWithCodex = invokeWith (providerRef "codex") planTask

ProviderRef引用配置中注册的provider,也可以覆盖本次调用的模型、推理强度和选项。 这意味着实际使用的时候可以采用不同模型的不同思考强度,例如简单的任务使用gpt-5.6-luna-max,负责的任务使用gpt-6-astra等等。 因此,同一个planTask可以交给不同provider执行,业务类型和后续组合保持一致。

执行时,Clef生成提示词和请求参数,经Tactus调用适配器; 适配器启动具体agent,并将最终回答放入统一结果的text字段; Clef再用任务的解码函数得到Plan。原生CLI的参数和输出格式由适配器处理,模型名称和选项保持开放,避免把厂商接口固化在核心类型中。


副作用接口:把外部操作变成可组合、可检查的步骤。

一次具体操作使用Operation output描述,简化内部字段名后如下:

data Operation output = Operation
  { effectName      :: Text
  , method          :: Text
  , params          :: Value
  , decodeOperation :: Value -> Either Text output
  }

perform :: Operation output -> Workflow output

它保存插件名称、方法、请求参数和结果解码函数,Value表示JSON值。 与接收业务输入的Task不同,Operation已经保存了本次操作的参数,所以只保留输出类型。 辅助函数operation将参数编码为JSON并附上结果解码函数,perform将操作接入工作流。

例如,内置的workspace.paths插件提供快照和差异比较。 Haskell包装接口为:

snapshot :: Operation WorkspaceSnapshot
diff :: WorkspaceSnapshot -> WorkspaceSnapshot -> Operation WorkspacePathDiff

快照记录路径元数据和内容摘要,差异结果列出新增、修改、删除及类型变化的路径。 工作流可以在agent调用前后采样,并据此决定是否继续:

当前插件在大文件夹系统下存在性能问题,当前仍在解决中。

import qualified Clef.Effect.WorkspacePaths as Paths

trackedPlan :: Text -> Workflow (Either WorkflowError Plan, Paths.WorkspacePathDiff)
trackedPlan request = do
  before <- perform Paths.snapshot
  outcome <- attempt (plan request)
  after <- perform Paths.snapshot
  changes <- perform (Paths.diff before after)
  _ <- perform (Paths.forget before)
  _ <- perform (Paths.forget after)
  _ <- requireBecause "规划阶段删除了文件"
    (null . Paths.workspaceDeletedPaths) changes
  pure (outcome, changes)

这里的控制分成两步:插件产生观察结果,工作流用requireBecause检查“不允许删除文件”这条规则。 条件不成立就产生RequirementFailed,阻止后续步骤继续; 检查通过后,流程返回规划结果和变化记录。 forget释放插件保存的快照元数据,_ <-表示执行步骤但不保留其返回值。

这里就体现出所谓的Harness的作用,如果业务规则可以被严格的检查,会很有力的约束agent的行为。 “检查文是否存在”或者”邮件是否正确发送“这些都是非常清晰的业务规则,对于持久化工作流推荐采用这种方法验证agent执行的结果。 这部分属于无人值守工作流的必要组分了,目前还开发的不是很完善。

attempt把WorkflowError转成Either,所以即使规划失败,程序也能继续比较文件变化。它不会吞掉异步取消,也不保证后续快照或检查一定成功。已发生的删除不会因检查失败而自动恢复。

调用前后的观察钩子。

如果每次provider调用都需要观察,可以在已注册的effect配置中设置observe_invocations = true。 Clef随后按下面的顺序组织调用:

  1. 调用observe.begin,取得插件保存的初始状态标识,例如快照ID。
  2. 调用provider执行任务。
  3. provider返回或抛出异常后,尝试调用observe.end,传回初始标识和调用状态,收集变化证据并收尾。

开始钩子失败时,provider不会启动; 结束钩子失败也会进入调用的错误处理。 钩子返回的证据会被记录,具体是否拒绝某种变化,需要插件报告失败或工作流显式检查。 任务最终回答的业务解码在这些钩子之后进行,因此provider调用成功也可能随后发生解码失败。

两类插件共用agenstro.plugin/v1协议:一个进程接收一个JSON请求,可以返回多条JSONL事件,最后交回一个对应的终结结果。Clef负责类型转换、组合和钩子顺序,Tactus负责进程监督、超时、取消和协议检查。接入新的provider或effect时,只要遵守协议,就可以独立选择实现语言。

副作用控制的强度由插件实现决定。workspace.paths提供变化观察;motivo.test则在Linux上将实验的持久写入限制在指定样本目录,并限制执行时长。普通Haskell仍可以通过liftIO执行外部操作,agent也有自己的工具调用,因此Workflow类型本身不能保证全面隔离或自动回滚。遇到PluginOutcomeUnknown时,外部操作可能已经发生,需要核对实际结果后再决定是否重试。

tactus-runtime, 运行时层

Tactus是整个项目的运行时层,目的是提供运行环境、生命周期和结果记录,承担三项职责:

  1. 准备执行环境:将项目配置、Haskell脚本和实际插件连接起来。
  2. 监督执行过程:管理进程、通信、资源限制、超时和取消。
  3. 判定并记录结果:区分成功、失败和结果未知,留下后续排查所需的证据。

这一层采用Rust,主要考虑它对系统进程和资源的管理能力(rust在多种操作系统下均具有稳定的表现),以及用类型明确表达状态和错误的便利。 工作流的业务逻辑仍留在Haskell中,Rust侧处理执行机制。

准备执行环境

tactus init在业务项目下建立.tactus目录,将运行所需的文件集中到项目内部:

.tactus/
  tactus.toml     插件注册和运行配置
  cabal.project   连接Clef SDK及Haskell依赖
  scripts/       业务工作流脚本
  runs/          运行诊断记录

tactus.toml把工作流引用的名称映射到实际命令。 provider、effect和通用插件分别注册在providers、effects和plugins下。 例如,providerRef "codex"引用的是一个注册名称,由配置决定它最终启动哪个适配器。 Tactus检查配置结构、默认provider是否已注册等条件,插件自己的选项则留给插件解释。

Haskell源码通过Cabal和GHC检查、执行。 将包含main入口的脚本保存好后,可以分别运行:

tactus check .tactus/scripts/010_plan_review.hs
tactus run --script .tactus/scripts/010_plan_review.hs

check检查源码和类型; run启动脚本,执行main函数。 启动前,Tactus把配置转换成Clef可读取的运行时JSON,并通过TACTUS_RUNTIME_CONFIG告诉脚本配置的位置。 前面出现的runTactus据此建立Runtime,再运行Workflow。

这是将基本设置与业务代码分离的较为标准的方式,主要解决了provider变更与迁移的问题,但是在实际长程任务中仍然不推荐频繁变更provider,即使是常规的模型升级也存在潜在的性能退化的可能(即使llm研发机构”声称“这一代全面强于上一代)。

监督插件调用

Tactus既负责启动Haskell脚本,也负责监督脚本中的插件调用。 为此,提供给Clef的插件命令统一指向tactus dispatch:工作流调用invoke或perform时,先进入这个分发入口,再由它启动配置中的实际插件。

一次插件调用大致经过以下步骤:

  1. 根据注册名称解析命令和参数,启动插件进程。
  2. 写入JSON请求,持续读取标准输出和标准错误,避免某一条管道堵塞。
  3. 检查JSONL消息的格式、请求ID和先后顺序,转发有效事件。
  4. 等待的终结结果和进程退出,完成所管理进程的清理。

插件的标准输出承载协议消息,标准错误承载诊断信息。

为了处理卡住或持续输出的插件,Tactus限制请求大小、单条消息大小、累计输出和待处理事件数量,并为调用设置执行期限。 Unix上通过进程组管理子进程,Windows上使用Job Object。 超时、取消或协议出错时,监督器终止并回收所管理的进程,记录清理结果。

目前此项目在windows和arch linux上均经过了测试。 但后续笔者可能会长期在linux上演进,后续需要跨平台测试。

判定结果并保留运行日志

Tactus综合终结消息和进程状态,对外归纳为三种结果:

enum OutcomeState {
    Succeeded,
    Failed,
    OutcomeUnknown,
}

Succeeded表示收到合法的成功结果,且进程正常退出; Failed表示有明确的失败依据; OutcomeUnknown表示外部工作可能已经发生,但缺少可以信任的最终结论。

目前OutcomeUnknow还缺少细致分类,正在研究此类问题。

例如,插件执行了外部操作,却在交回结果前超时,直接重试就可能重复执行。 Tactus保留这种不确定性,供工作流或用户核对实际结果后决定下一步。 即使执行被判为成功,返回值是否符合Plan结构、方案是否满足需求,也仍分别由Clef解码和业务校验判断。

每次运行的诊断信息写入.tactus/runs/<run-id>/。 events.jsonl按序记录调用进展和状态变化,summary.json保存可用的终结摘要。 状态变化同时记录原状态、触发原因、判断依据和新状态,方便后续排查。

motivo, 交互层

交互层里面有一个基本理念叫做human in the loop。 笔者个人认为目前的技术而言,完全做到无人值守,也就是一句prompt或者一个goal放任去完成一整个任务几乎是不现实的。 因此motivo的主要目的就是为了实现一个良好的human in the loop。 这里面有几个条件需要满足:

  1. 易用性:用户使用起来应该足够的简单,最佳的状态是与用户平时的使用习惯完全兼容,避免用户去适应工具
  2. 可观测性1:异常处理,用户至少能简单的确认进程活着,而且错误的时候看到人类可读的错误类型
  3. 可观测性2:程序表现,最好是让用户在不查看程序结果的时候,通过agent反馈的信息就能大致判断完成质量,减少迭代时间。
  4. 持续进化:即工作流能够很方便的迭代,这意味着修改、维护和插入新程序的功能要有

笔者早期受制于传统图形化软件的思想,非得搞个图形化界面。 后发现现在完全无需如此,因此采取了一个Human —[skills]—> agent —> Agenstro —> agent —[skills]—>Human 的方式。 干脆让agent作为交互的媒介,这样用户的使用完全没有阻碍。 当然一些精通haskell编程的也可以直接编写脚本,tactus提供了完善的命令行。

不过这部分仍然在开发当中,主要是这一块就只能靠多试错来。 并且一旦没弄好还会起到反方向的作用

用方法组织任务

初始化项目时,Tactus会安装Motivo skill及相应模板。主agent根据当前问题选择方法,准备输入,再调用模板整理材料或执行实验。 目前提供八种方法:

  • 方法:clarify,澄清;要解决的问题:目标、约束和成功标准是什么?
  • 方法:investigate,调查;要解决的问题:哪些实际证据可以回答当前疑问?
  • 方法:analyze,分析;要解决的问题:哪种解释更符合已有证据?
  • 方法:research,研究;要解决的问题:外部资料支持哪些方案,各有什么限制?
  • 方法:probe,最小实验;要解决的问题:能否用一个小实验区分不同假设?
  • 方法:organize,组织;要解决的问题:如何划分工作单元,安排真实的依赖关系?
  • 方法:retrospect,复盘;要解决的问题:预期与实际为何不同,下一次应改变什么?
  • 方法:handoff,交接;要解决的问题:后续的人或agent需要哪些信息才能继续?

这些方法按需要选择。 例如调查发现问题已经明确,就可以直接组织实现; 只有某个假设需要验证时,才补做实验。 方法名称对应不同的思考目的,不要求任务依次经过八个阶段。

模板位于.tactus/motivoscript,通常接收主agent准备的Markdown材料。 已有模板可以重复使用,每次只更换输入。 默认情况下,记录这些材料不会再调用模型; 确实需要独立上下文时,才通过模板的--provider参数选择一次额外调用。 该调用使用Tactus的配置,拥有自己的上下文。

agent编写、人审阅工作流

前面提到“让LLM写Haskell、由人审阅”,在这里落实为一套分工:Motivo帮助主agent梳理问题,Tactus skill说明脚本如何编写和检查,Clef提供可组合的程序接口。

需要执行业务工作时,主agent把确定的工作单元写到.tactus/scripts,用Task声明输入输出,用Workflow表达顺序、分支和检查条件,再通过tactus check取得编译反馈并修正代码。 方法模板和业务脚本分别存放,因此默认的tactus run --all只选择业务脚本。

用户审阅时,可以沿着程序中的问题逐项判断:

  • 任务是否覆盖了原始需求,步骤之间的数据是否足够?
  • 哪些步骤会修改外部状态,修改范围是否合理?
  • 什么条件允许流程继续,失败后应当怎样处理?

编译器帮助排除类型连接错误,人则关注任务划分、业务约束和验证方法。编译通过仍不能证明模型判断正确,也不能代替实际测试。 用户可以在原会话中修正目标、调整步骤或检查结果,主agent据此修改脚本并重新检查。审阅的时机和粒度由具体任务决定,这就是这里的human in the loop。

通过证据和报告形成反馈

只留下最终结论,很难判断一个方案为什么被采用。 Motivo围绕原问题、来源、观察、解释和下一步组织记录,缺失的信息保留为未知。 每次方法调用在.tactus/motivo/runs/<run-id>/保存请求、样本记录、报告和相关产物,并关联对应的Tactus运行记录。

这里两种记录有不同用途:Tactus记录进程做了什么、如何结束;Motivo记录这些事实对当前问题意味着什么,以及哪些判断还需要验证。 agent的解释与实际执行结果分别保存,方便用户检查结论是否有证据支持。

需要实验时,probe方法通过Clef调用motivo.test插件,把实验放在受限的样本目录内执行。报告保留预期现象、实际结果和日志;非零退出或超时也作为观察保存,由主agent判断它们如何影响后续工作。 实验结果需要进入正式实现时,再形成相应的业务脚本。

Motivo Studio将这些记录呈现为离线HTML报告,项目总览位于.tactus/motivo/index.html。 报告由Haskell生成,使用HTML、CSS和少量JavaScript,核心内容和样式包含在页面中,浏览时无需启动服务。 页面展示已保存的证据,刷新后可以读取新的报告;用户的反馈和后续操作仍通过原来的agent会话进行。

这也使复盘和交接有了具体材料:后续的人或agent可以查看已经尝试的方法、失败的实验、当前结论和未解决问题,再决定从哪里继续。

继续阅读