feat: add the dsh-superagent research prototype
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# 000 Philosophy
|
||||
|
||||
dsh-superagent is a framework, and also a way of working with an agent. Its core principle:
|
||||
|
||||
**Take as little human attention as possible, and give as much value as possible.**
|
||||
|
||||
Every part of the project is judged by the attention it costs. A message, a notification, a question, a setting, a panel element and a paragraph of documentation each cost someone's attention, and each has to give more value than that, or it is removed.
|
||||
|
||||
## What follows for the agent
|
||||
|
||||
- **It acts only when asked, or when a written rule allows it.** A request counts only when it addresses the agent directly and comes from a person the team allowed: one on an allowed list, or anyone in a conversation the team opened to requests. Work nobody asked for needs a rule the team wrote, and starts in a mode that only records what the agent would have done.
|
||||
- **It speaks only when a person must act or can use a result.** It confirms a request once and names what it will do. It stays quiet while it works. It writes again when a result is ready, when it needs a decision, or when it cannot finish. Messages are short and go to the conversation where the request was made.
|
||||
- **It asks one concrete question at a time,** in a structured form, so the question reaches the person instead of staying inside a long report.
|
||||
- **It stops by impact, not by effort.** A local change goes as far as a reviewable result. A change that needs a design decision stops with a clear question.
|
||||
- **It reports honestly.** A draft is marked unfinished, a failure is reported as a failure, and a limit is never bypassed to avoid asking.
|
||||
|
||||
## What follows for the design
|
||||
|
||||
- **Few concepts.** The design has only its domain concepts, such as a Source, and their concrete implementations, such as GitHub and Feishu. A business need becomes an attribute of one of them, or a prompt the agent follows. The design does not add a concept for it.
|
||||
- **Few settings.** A default replaces a setting when one value suits almost every team. Behaviour that a team describes in words is prompt text, not a setting.
|
||||
- **A panel that shows only what needs a person.** It lists first what waits for a person. A healthy state takes no space.
|
||||
- **Documents read in order.** Each numbered document builds only on the ones before it, so a reader never needs a concept that has not been introduced yet.
|
||||
|
||||
## Responsibility
|
||||
|
||||
The agent comments, opens pull requests and messages people under accounts your team sees every day. Every message it sends costs your colleagues' attention, and you are responsible for it. The more the agent can do, the more care its operator owes the team:
|
||||
|
||||
- **Start narrow.** Begin with a short allowed list and with proactive work only recorded. Widen both once what the agent would have done looks right.
|
||||
- **Tune for less noise.** When the agent is noisy, make its prompts shorter and stricter, or pause it. Do not add more messages to explain the noise.
|
||||
- **Do not use it to produce volume.** Mass pull requests, broadcast messages and busywork cost the team more than they give.
|
||||
- **Own what it says.** Read what the agent posts in your name, and correct it in the prompts when it is wrong.
|
||||
|
||||
The goal is the right help at the right time, and nothing at any other time.
|
||||
@@ -0,0 +1,112 @@
|
||||
# 001 Domain model
|
||||
|
||||
This document names the concepts of dsh-superagent and the rules each one keeps. The code, the tools the agents call, the panel and the other documents use these terms and no others. Following [000 Philosophy](000-philosophy.md), the model holds only concepts the work needs, and a business need becomes an attribute of one of them.
|
||||
|
||||
## Flow
|
||||
|
||||
Work moves from Trigger to Eval to execution. A Source produces Triggers. A Trigger fans out as context to the Tickets subscribed to its Threads. A person's message that reached a Ticket this way stays with that Ticket; every other Trigger enters Eval, which decides whether it asks for new work. When Eval accepts, it creates a Ticket with a Mandate. An Assignment hands the Ticket to an Executor, and results go back to the Source through a Channel.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S[Source] -->|Trigger| T[Thread]
|
||||
T -->|batch| E[Eval]
|
||||
T -->|Subscription fan-out| K[Ticket]
|
||||
E -->|accept with Mandate / stop| K
|
||||
E -->|ignore / unavailable| X[recorded]
|
||||
P[Policy change] -->|notice| K
|
||||
K -->|Assignment| W[Executor]
|
||||
K -->|Channel| S
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
Source, Thread, Eval, Mandate and Subscription form the core domain; the rest supports them.
|
||||
|
||||
| Concept | Definition | Rules it keeps | Why it exists |
|
||||
| --- | --- | --- | --- |
|
||||
| Source | A stream of events. A Source kind is a reusable implementation; a Source instance is one configuration of a kind: whether it runs and what it listens to | Holds its read position and its own bot identity. Defines what counts as an event, including conditions that only hold over time. Drops the bot's own messages and Triggers outside what it listens to before they enter the domain. Translates each Trigger into the Threads it belongs to and the Channel addresses to reply on | A restart must not replay events |
|
||||
| Trigger | One immutable event with the Source's own metadata, such as its actor | Belongs to one or more Threads. Accepting a Trigger twice creates one Ticket | The unit of audit and of idempotence |
|
||||
| Thread | Triggers in time order; the Source defines which Triggers form one Thread | At most one Eval runs on it at a time. It may have several Tickets. It holds its Subscriptions, the running Eval and its Decision history | Gives Eval a consistent view and its context |
|
||||
| Correlation | The relation between Threads that one Trigger belongs to | Adds context; never routes | Lets Eval follow related evidence |
|
||||
| Channel | The write side of a Thread; the Source defines its addresses | A Thread may have several Channels; a Ticket holds a set of them | Acknowledgements, replies, pull requests and resolved reviews are all writes |
|
||||
| Policy | The versioned assessment rules and each Source's part: who may originate work through that Source, in that Source's own identities, how the list is synchronized, and which bots may make candidates | The Policy version covers the rules, the accept mode and the Source parts. A change that bears on a live Ticket, such as a change to a rule it was accepted under, is delivered to its Assignment; it does not revoke Mandates. Identities on different Sources are never mapped to each other | When the rules change, a running Eval must be discarded |
|
||||
| Eval | The judgement on a batch of Triggers, in two layers: gate and assessment | Input: the Trigger batch, a Thread snapshot, every Ticket on the Thread including ended ones, and the current Policy. Output: a Decision. Holds no state | Decides whether new work starts, goes on or stops |
|
||||
| Mandate | What an accept grants: the Policy version, the scope, the strength, the matched rules, the sender's identity on that Source and how the Source received the Trigger | Immutable; wider scope means a new Ticket. A Ticket's end does not end it: only a person's stop withdraws it, and a Policy that no longer holds it revokes it. Revalidated against the current Policy before an ended Ticket resumes | States why the work may be done, and how far it may go |
|
||||
| Ticket | A unit of authorized work | Only Eval creates one. Refers to its Mandate; holds its Channels and Inputs. Goes open → ended, and back to open when a person's message on one of its Threads resumes it, unless a person stopped it. Defines its own state instead of following the Executor's | Owns the execution and its reports |
|
||||
| Assignment | The binding of a Ticket to one Executor instance | A Ticket has several over time. Goes pending → running → ended independently of the Ticket. Only its final report may end the Ticket on the Executor's side | The Executor is outside the domain, but who executes must be inside it |
|
||||
| Subscription | A many-to-many relation between Tickets and Threads, held by the Thread | A matching Trigger fans out to every live subscribed Ticket, with no check of who sent it; a person's message that reaches a Ticket this way takes no Eval. A Thread an Executor follows for context reaches its Ticket the same way, but lends it no authority and does not resume it. An ended Ticket keeps its Subscriptions, including that of a pull request that closed or merged: a person's message resumes it under the same Mandate, with no Eval or Decision; messages from bots and CI do not reach it, since its Executor reads its Threads again when it resumes. A Ticket a person stopped receives nothing. The main Session attaches a Thread to an existing Ticket only when its message points at that Ticket explicitly. A person can drop every Subscription on a Thread; an Executor can drop only its own Ticket's Subscription to a Thread it finds unrelated, except the Thread its Ticket started on and a Thread a Source made it follow, such as its pull request's | Later discussion reaches the Executor without anyone mentioning the agent again |
|
||||
|
||||
A person is not a domain concept. A Trigger carries its actor's identifier as the Source gives it. An allowed list is a Source's part of the Policy, in that Source's identities. That one person has accounts on several Sources is prompt text that the agents read when they need it.
|
||||
|
||||
The Executor, with its Sessions and directories, is outside the domain and connects through Assignments.
|
||||
|
||||
Aggregates: a Source holds its read position. A Thread holds its Subscriptions, its running Eval, which runs serially and merges Triggers that arrive meanwhile into the next batch, and its Decision history. A Ticket holds its Assignments, Channels and Inputs, and refers to Decisions by id.
|
||||
|
||||
## Rejected concepts
|
||||
|
||||
| Rejected | Reason |
|
||||
| --- | --- |
|
||||
| Principal or Membership as an entity, or mapping identities across Sources | A person is Trigger metadata; an allowed list is a Source's part of the Policy; a cross-Source mapping is prompt text |
|
||||
| Eval scheduling its own delayed Triggers | A condition that needs waiting is an ordinary Source, so Eval never depends back on Sources |
|
||||
| Waiting or revoked Eval states | Waiting belongs to Sources; an Eval that runs out of time queues its Triggers again, or ends `unavailable` |
|
||||
| At most one live Ticket per Thread | One Thread can carry several independent pieces of work; the assessment avoids duplicates from the Ticket list |
|
||||
| An "append to Ticket" Decision | Appending is Subscription fan-out and needs no Eval |
|
||||
| Admission checks on Subscriptions | A Subscription covers the whole Thread; the Mandate separates authorization from context |
|
||||
| Correlation that routes | Subscriptions route; Correlation only adds context |
|
||||
| Ticket state that follows the DSH Session | A Ticket can be handed off, resumed, or run by several Executors, so it defines its own state |
|
||||
| An Executor declaring that a person asked it to stop | Only Eval decides the strength of an end |
|
||||
| Cancelling running Tickets on a Policy change | A cancelled change leaves half-finished work; the change is recorded and delivered instead |
|
||||
| Locking all of an Executor Session's tools when it fails or blocks | It would refuse a person working directly in the Session, and the report after recovery; pausing automatic input is enough |
|
||||
| Inferring "waiting on a person" from a report's free text | The classification needs structured facts; the Executor asks through a structured field |
|
||||
|
||||
## What is reusable
|
||||
|
||||
The concepts above and their rules are reusable. The Source implementations, the Policy contents, the Executor and the Channel implementations belong to a deployment. Each Source keeps its own words internally, such as message, chat or notification, and translates them into Triggers.
|
||||
|
||||
| Part | In this repository |
|
||||
| --- | --- |
|
||||
| Source | Feishu: live events, history recovery, polling of subscribed topics. GitHub: notifications, subscribed Issues and pull requests, CI changes, pull requests merged or closed, and, where switched on, every new Issue, pull request and comment in the repository and the default branch's CI |
|
||||
| Policy | Assessment rules, and each Source's allowed list, its sync, its bot list and its admission table |
|
||||
| Executor | A DSH Executor Session and a directory per Ticket, in an execution environment whose variables the Executor and the Sources register |
|
||||
| Channel | Feishu topic replies, GitHub comments and review thread replies |
|
||||
|
||||
## Glossary
|
||||
|
||||
A reference list of the terms this and the later documents use, with their Chinese names; the later documents explain the terms not introduced above. The panel shows one language at a time; its (?) button opens this glossary with both languages. The messages agents send to people on GitHub and Feishu use plain language, not these terms.
|
||||
|
||||
| Term | 中文 | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Agent | 智能体 | The automated team member dsh-superagent runs, acting on Sources through identities such as a GitHub account and a Feishu bot |
|
||||
| Main Session | 主 Session | The DSH Session that evaluates Triggers, accepts and dispatches Tickets, and replies; `orchestrator` in code and prompts |
|
||||
| Executor Session | 执行器 Session | The DSH Session an Executor runs for one Ticket; `worker` in code and prompts |
|
||||
| Source | 来源 | A stream of events: a kind that a plugin provides, plus one instance configuration |
|
||||
| Trigger | 触发 | One immutable event from a Source |
|
||||
| Thread | 线程 | Triggers in time order; the unit within which Evals run one at a time |
|
||||
| Correlation | 关联 | The relation between Threads that share a Trigger; it adds context only |
|
||||
| Channel | 渠道 | The write side of a Thread |
|
||||
| Policy | 策略 | The versioned rules for accepting work, and each Source's part of it, such as who may ask for work there |
|
||||
| Policy version | 策略版本 | The identifier of one combination of rules, Source parts and accept mode; every Mandate records it |
|
||||
| Eval | 评估 | The process that turns a batch of Triggers into a Decision |
|
||||
| Gate | 准入 | The deterministic first layer of Eval: who may originate work, and with what strength |
|
||||
| Assessment | 研判 | The model's judgement during Eval |
|
||||
| Decision | 决定 | The result of Eval |
|
||||
| Mandate | 授权 | What an accept grants: strength, scope and the Policy version it relied on |
|
||||
| Ticket | 工单 | A unit of authorized work |
|
||||
| Assignment | 派工 | The binding of a Ticket to one Executor instance |
|
||||
| Subscription | 订阅 | The relation between a Ticket and a Thread |
|
||||
| Fan-out | 扇出 | Delivering Triggers of a subscribed Thread to a Ticket as context |
|
||||
| Executor | 执行器 | The port that runs Assignments: one Executor Session and one directory per Ticket |
|
||||
| Execution environment | 执行环境 | Where Executor Sessions and the prepare command run: variables that the Executor and the Sources register, each with a key, a value and a description |
|
||||
| Report | 报告 | The state an Assignment reports to its Ticket |
|
||||
| Attention | 待人工决定 | A question on a Ticket that waits for a person's answer |
|
||||
| Disposition | 去向 | The record of a Trigger being filtered, evaluated or fanned out |
|
||||
| Input | 输入 | Context delivered to a Ticket: fanned-out Triggers, Policy changes, resumes and text |
|
||||
| Scope | 范围 | The work a Mandate allows |
|
||||
| Strength | 强度 | How strong the basis of a Mandate or an Eval is |
|
||||
|
||||
| Group | Values |
|
||||
| --- | --- |
|
||||
| Decision | accept 接单 · stop 停止 · ignore 忽略 · unavailable 未完成 |
|
||||
| Strength | explicit 明确 · inferred 推断 |
|
||||
| Assignment | pending 待启动 · running 执行中 · ended 已结束 |
|
||||
| Report | progress 进展 · waiting 等待 · ready 就绪 · blocked 受阻 · failed 失败 · dropped 无需处理 |
|
||||
@@ -0,0 +1,155 @@
|
||||
# 002 Authorization
|
||||
|
||||
Authorization determines who may start work and what that work may include. This document builds on [001 Domain model](001-domain.md).
|
||||
|
||||
The host enforces allowed lists, rules and Mandates. Messages, comments and quoted documents are inputs to a decision; they cannot change those permissions.
|
||||
|
||||
## Two paths for a Trigger
|
||||
|
||||
### Eval: deciding whether to start work
|
||||
|
||||
The Source records whether a message addresses the agent: `mention` for a mention of the bot or agent account, and `direct` for a direct message. The gate checks the sender and the Source's Policy:
|
||||
|
||||
- An allowed person who addresses the agent makes an explicit request.
|
||||
- A person's follow-up on a Thread with Tickets follows the rules below.
|
||||
- Other human messages use the admission table for their receiving scope.
|
||||
- A bot on the Source's bot list can produce an inferred candidate, never an explicit request. Other bots are not admitted.
|
||||
|
||||
### Subscriptions: continuing existing work
|
||||
|
||||
Every message on a subscribed Thread reaches its subscribed Tickets as context, including messages from bots and CI. This delivery bypasses the gate and grants no new authority. Each Ticket remains limited by its Mandate.
|
||||
|
||||
A person's message on a Ticket's own Thread belongs to that Ticket's Executor to decide. The same applies when the main Session attaches a message to a Ticket. While an Executor owns the message, it takes no Eval and gets no Decision. Each Executor either handles the request within its Ticket's scope or hands it back for routing elsewhere.
|
||||
|
||||
Ownership continues while the Ticket is live, even if a person removes its Subscription. It also covers an ended Ticket that was not stopped by a person and has the message waiting to resume it.
|
||||
|
||||
The main Session receives a follow-up notice. While an Executor owns the message, the main Session may only stop work on the person's request; accept, attach, ignore and requeue are refused. A stop still passes through the gate with the follow-up strength described below.
|
||||
|
||||
### Handing back and releasing follow-ups
|
||||
|
||||
An Executor hands a message back once when it asks for work beyond the Ticket's scope or belongs to another Ticket. The Ticket keeps its Subscription. If an attached Thread is unrelated, the Executor hands back the messages it owns there.
|
||||
|
||||
A message goes to a fresh Eval only after no Ticket still owns it. Its candidate includes the Executors' reasons. The gate judges it as an ordinary message on that Thread, and the main Session can accept it, attach it elsewhere or ignore it. Handing back a message the Ticket did not own, such as a bot message or one a person already sent to Eval, queues nothing.
|
||||
|
||||
When a Ticket ends, it releases follow-ups its Executor did not read or take up. These include messages never delivered to the Executor and messages delivered after its last Report or turn end, provided it neither acknowledged, answered nor handed them back. Each released message goes to Eval when no other Ticket owns it. A stop ends only the stopped Ticket's work.
|
||||
|
||||
A stopped Ticket does not send its stop message to Eval again unless another Ticket handed it back. The main Session that stopped it may accept the rest of the message as new work.
|
||||
|
||||
A person can send an owned follow-up to Eval from the panel. If the main Session then attaches it back to a live Ticket, the Executor receives it again. The panel also offers re-evaluation when no effective judgement covers the message.
|
||||
|
||||
### Context-only subscriptions
|
||||
|
||||
An Executor can follow a related Thread, such as another Issue or pull request, for context. Its messages reach the Ticket but grant no authority or follow-up strength. Human messages there do not resume an ended Ticket unless the main Session attaches a message from that Thread. Notes from the main Session about other work are also context only.
|
||||
|
||||
Human messages that reach no Ticket, including Threads whose Tickets were stopped by a person, take the Eval path. Bot messages take that path independently of their delivery to subscribed Tickets.
|
||||
|
||||
## Strength
|
||||
|
||||
| Strength | Cases |
|
||||
| --- | --- |
|
||||
| explicit | A person on the list addresses the agent, or follows up on a Thread that already has a Ticket; a cell of the admission table set to explicit request |
|
||||
| inferred | A person not on the list follows up on a Thread with a Ticket, unless their cell of the admission table is explicit; a cell of the admission table set to inferred candidate; a message of a bot on the Source's bot list that addresses the agent or arrives where the Source receives every message |
|
||||
| not admitted | Any other bot's message, everything the admission table ignores, and every inferred case while the accept mode is `off` |
|
||||
|
||||
For an unlisted person, an explicit admission cell takes precedence even on a Thread with Tickets and works in every accept mode. Otherwise their follow-up is inferred and is not admitted in `off` mode. Messages on Threads followed only for context use the admission table instead of follow-up strength.
|
||||
|
||||
## Admission
|
||||
|
||||
Each Source's Policy has an admission table. Its rows are receiving scopes:
|
||||
|
||||
- **Feishu:** each configured chat, other chats the bot receives mentions from, and direct messages. Other chats are received only when **其他群** is set to **仅 @** on the Feishu card.
|
||||
- **GitHub:** new Issues, new pull requests, comments and the default branch's CI.
|
||||
|
||||
A row has up to three cells. Each cell decides whether a message becomes an explicit request, an inferred candidate or nothing:
|
||||
|
||||
| Cell | Choices | Default |
|
||||
| --- | --- | --- |
|
||||
| On the list, not addressing the agent | inferred, ignore | inferred |
|
||||
| Not on the list, addressing the agent | explicit, inferred, ignore | ignore |
|
||||
| Not on the list, not addressing the agent | inferred, ignore | ignore |
|
||||
|
||||
Cells for unaddressed messages are available only where the Source receives every message, such as a Feishu chat set to **全部消息** or a GitHub scope with its receive-all switch enabled. A CI result has no sender, so its row offers only the last cell. Bot messages use the bot list instead of these cells.
|
||||
|
||||
Setting the unlisted-addressed cell to explicit lets anyone in that scope request work by mentioning the agent. This can be useful for a dedicated support chat. Inferred candidates still depend on the accept mode. Follow-ups normally bypass this table, except that an explicit cell still grants explicit strength.
|
||||
|
||||
The admission table is part of the Policy version. Weakening a cell does not immediately stop open Tickets accepted under it. It can revoke their Mandates when they are revalidated before resuming.
|
||||
|
||||
### Bots and automated reviewers
|
||||
|
||||
The host recognizes GitHub Apps, logins ending in `[bot]`, and Feishu bots as bots. A Source can list bots such as `dependabot[bot]` whose messages may become inferred candidates. They must either address the agent or post in a scope that receives every message. They cannot make explicit requests or stop Tickets.
|
||||
|
||||
An automated reviewer using an ordinary account counts as a person to the host. Its comments reach subscribed Tickets and generate follow-up notices. Team prompt text must identify such accounts as automated reviewers.
|
||||
|
||||
## Routing candidates
|
||||
|
||||
The main Session routes explicit candidates with `orchestrator_route`. One call can contain several accept, attach, stop or ignore decisions. Each item is processed independently: a refused item or a missing field does not prevent the others from running.
|
||||
|
||||
An inferred candidate normally needs a full assessment. There is one shortcut: if its card clearly touches no enabled rule, such as plain chat or unrelated discussion, the main Session may ignore it directly. The Decision records `ignoredFromCard` to distinguish this from a rule assessment. The main Session reads the enabled rules in its Policy prompt section.
|
||||
|
||||
Accepting an inferred candidate always requires an assessment. Direct accept refuses a Trigger that the gate did not admit as explicit.
|
||||
|
||||
## Accept mode and rules
|
||||
|
||||
The accept mode is part of the Policy:
|
||||
|
||||
| Mode | Effect |
|
||||
| --- | --- |
|
||||
| `off` | Nothing inferred becomes a candidate, such as an unaddressed message, a bot's message or the follow-up of a person not on the list |
|
||||
| `observe` (default) | Inferred candidates are assessed; a positive assessment is recorded as ignore marked "would accept", and nothing is accepted |
|
||||
| `auto` | A positive assessment of an inferred candidate accepts it |
|
||||
|
||||
Explicit requests work in every mode. Start in `observe` and switch to `auto` after reviewing the recorded Decisions. Each assessment judges every enabled rule exactly once, whether it matches or not. A proactive accept requires at least one matching rule.
|
||||
|
||||
Each rule describes a kind of proactive work. The Ticket's Mandate records the matched rules, and its Executor receives their names and instructions.
|
||||
|
||||
For that Ticket, a rule's process and communication requirements override the defaults. A silence rule can suppress an acknowledgement; a review rule can name the required reviewer and approval before merge. Requirements that apply to every Ticket belong in team prompt sections instead.
|
||||
|
||||
## Policy version
|
||||
|
||||
Rules, accept mode and each Source's Policy form the Policy version. Every Mandate records the version under which it was accepted.
|
||||
|
||||
A version change discards running Evals and queues their Triggers again. It also sends a policy-changed Input to each affected live Ticket. The Input explains whether:
|
||||
|
||||
- a matched rule changed, including its new text, or no longer applies;
|
||||
- the requester may no longer start work; or
|
||||
- proactive work is no longer accepted, for example because the mode changed from `auto`.
|
||||
|
||||
Unaffected Tickets receive no notice. The change does not immediately revoke live Tickets' Mandates.
|
||||
|
||||
Source configuration is separate from Policy. It controls what enters the system: which chats are read and whether a scope receives all messages or only mentions. A Mandate records its Source and receiving scope. Resuming needs the Source to still listen to the Thread, but does not require it to still receive every message there.
|
||||
|
||||
Replies wait while their Source is off. They are dropped if the Source no longer listens to their destination.
|
||||
|
||||
A condition on what the Executor may do with its own result, such as when it may merge, does not decide who may authorize work. It is part of the agent's instructions, not of the Policy.
|
||||
|
||||
## Resume and stop
|
||||
|
||||
An ended Ticket keeps its Mandate. A person's message on one of its own Threads can resume the same work without a new Eval or Decision, regardless of who sent it. Bots and CI do not wake ended Tickets. The Executor decides whether the request fits the scope and ends the round if it does not.
|
||||
|
||||
Only three operations change a Mandate: accept creates it, a person's stop withdraws it, and failed revalidation revokes it. A Ticket stopped by a person stays ended and receives nothing more. Work requested there needs a new accept.
|
||||
|
||||
### Revalidation before resume
|
||||
|
||||
The Source must still listen to the original Thread. The remaining checks depend on the Mandate's basis:
|
||||
|
||||
- An explicit follow-up Mandate passes.
|
||||
- An inferred Mandate needs `auto` mode and its matched rules still enabled.
|
||||
- A Mandate admitted by a table cell must still receive at least its original strength under today's table and sender membership. Removing a sender from the list can therefore revoke a Mandate accepted through the listed-sender cell.
|
||||
- A bot's Mandate requires that bot to remain on the Source's bot list. Inferred table and bot Mandates also need `auto` mode and enabled matched rules.
|
||||
- Other Mandates require the sender to remain on the Source's allowed list.
|
||||
|
||||
If revalidation fails, the Ticket stays ended and takes none of its waiting messages. Each message is treated as having reached no Ticket; if no other Ticket owns it, it goes to Eval. The main Session is not told it reached a worker. This also applies after a processing pause. Attaching to the Ticket is refused, and further work requires a new accept.
|
||||
|
||||
- **A person saying "stop" in a Thread is an ordinary Trigger,** and Eval decides stop. An Executor cannot declare that a person asked it to stop.
|
||||
- **Stop in the panel is a command.** It acts on the Ticket directly and is recorded as a manual stop.
|
||||
- **Waiting conditions belong to Sources.** A condition such as "no assignee for N hours" would be a Source that waits and checks again itself; Eval never waits.
|
||||
|
||||
## When Eval does not finish
|
||||
|
||||
The time limit starts when the candidate reaches the main Session. Time spent waiting for delivery does not count, and an undelivered candidate never expires.
|
||||
|
||||
An expired Eval queues its Triggers for another attempt. After two consecutive retries, the third expiry records `unavailable`. An assessment failure also records `unavailable`; a later Trigger can start a new Eval. A Policy change or a decision made against an outdated Policy records `unavailable` and immediately queues the batch under the current version.
|
||||
|
||||
The `evals` health entry reports unresolved unavailable Decisions from the last hour. `scripts/dsh-superagent status` includes that error.
|
||||
|
||||
The panel asks for human attention only when the unavailable Decision is less than 24 hours old, is still the latest Decision on its Thread, has not been dismissed, and the Thread is neither queued nor evaluating. A person can evaluate the Trigger again or dismiss the problem.
|
||||
@@ -0,0 +1,203 @@
|
||||
# 003 Execution
|
||||
|
||||
This document follows an accepted Ticket through execution, delivery and recovery. It builds on [001 Domain model](001-domain.md) and [002 Authorization](002-authorization.md).
|
||||
|
||||
## Ticket stages
|
||||
|
||||
Each open Ticket has one stage, determined by what it is waiting for. Read the table from top to bottom; the first matching row wins.
|
||||
|
||||
| Stage | Condition | Panel label |
|
||||
| --- | --- | --- |
|
||||
| person / delivery | Delivering an Input to the Executor Session failed; it is retried after 2 seconds, then after a delay that doubles up to a minute, and a success, a new Assignment or the Ticket's end clears it | Input undelivered |
|
||||
| person / answer | It has an Attention | Awaiting answer |
|
||||
| person / review | The latest Report is `ready` | Awaiting review |
|
||||
| person / blocked, failed | The latest Report is `blocked` or `failed`, and no person's message reached the Ticket since | Blocked · Failed |
|
||||
| working / queued | No Assignment, or a pending one | Queued |
|
||||
| idle | The latest Report is `waiting`, or the Assignment ended | Waiting for a Trigger |
|
||||
| working | Everything else | Working |
|
||||
|
||||
A `person` stage means the Ticket cannot continue without human input. A new human message releases a `blocked` or `failed` Ticket to its Executor, so it moves to the next matching stage, usually `working`.
|
||||
|
||||
An `idle` Ticket has finished its turn without an outstanding question. A Trigger on a subscribed Thread can continue it.
|
||||
|
||||
## The Executor
|
||||
|
||||
Each Ticket gets an Executor Session named `worker-<ticket id>` and a directory named after the Ticket's short name. Directories are created under the configured execution root, which defaults to `<stateDir>/work`.
|
||||
|
||||
Executor Sessions run commands without approval as the operator's OS user. Their `dsh-superagent-worker` preset contains the tools needed for work and moves foreground shell commands to background jobs after 5 seconds. This lets a worker receive new messages at the next step instead of waiting for a long command to finish.
|
||||
|
||||
The main Session uses `dsh-superagent-router`, with the same 5-second shell and job-wait limits and a smaller tool set for routing. Sessions retain the preset selected at creation. Worker subagents use DeepSeek Harness's `standard` preset.
|
||||
|
||||
### Background work and worker slots
|
||||
|
||||
When a turn ends with shell jobs or subagents still running, the Ticket keeps its worker slot and stays working. It is not reported as waiting, and the main Session receives no waiting report. A completed job or subagent wakes the Executor with its result in the same slot. Once a turn ends with no background work, the Executor reports waiting normally.
|
||||
|
||||
After each turn, background work has at most 30 minutes to finish. At that limit, shell jobs are killed and the Ticket is reported waiting. The report includes the Executor's last words and which jobs were killed or subagents remain active. Subagents are allowed to continue. The Executor reads killed-job results on its next turn.
|
||||
|
||||
A handoff to a person frees the slot immediately and leaves the Report in effect. This applies after a `ready` Report or a question, unless another Input arrived since, and after any `blocked` or `failed` Report.
|
||||
|
||||
At handoff, subagents stop. Shell jobs, such as a server offered for review, may continue until the Assignment ends. They hold no slot and are exempt from the 30-minute limit, including after later turns. Their completion does not wake the Executor; it reads the results on its next turn.
|
||||
|
||||
A person can also start a turn by writing directly in the Executor Session. Its background work follows the same rules.
|
||||
|
||||
When an Assignment ends, its shell jobs are killed without waking the Session. Its subagents are released, and any completion turns they wake are dropped. The replacement Assignment and its waiting Inputs start after that shutdown finishes.
|
||||
|
||||
Stopping or restarting the plugin inside the DeepSeek Harness process uses the same shutdown path. Inputs not yet taken into Session history are retained. Shutdown waits up to 10 seconds for each worker's background work; a process restart ends that work outright. An Assignment interrupted while busy restarts with a recovery note.
|
||||
|
||||
Worker subagents, including nested ones, use DeepSeek Harness's `standard` preset with the worker's permissions. They retain only the worker's allowed work tools, and lose those tools when the Ticket ends. Under `standard`, a shell command moves to the background after 60 seconds. A subagent's jobs are killed when it ends.
|
||||
|
||||
### Preparing a Ticket directory
|
||||
|
||||
Before the first Assignment, if the Ticket directory does not exist, the prepare command runs once with `sh` in the execution root. It has ten minutes to finish. Without a prepare command, the worker starts in an empty directory.
|
||||
|
||||
A failed command fails the Ticket and records its output. A retry or a person's message on one of the Ticket's Threads tries preparation again. A typical command uses `git worktree` to create a checkout. A pull request belongs to the Ticket when its branch's last path segment matches the Ticket's short name.
|
||||
|
||||
### Concurrency
|
||||
|
||||
A Ticket holds a worker slot from the start of a launch, including preparation, or from the delivery that wakes its idle Executor. It also holds the slot throughout an active turn.
|
||||
|
||||
The configured concurrency limit applies to these slots, with one extra slot for a person's message or a status request. Such a wake can use the extra slot when normal slots are full, and takes the next free slot before a new launch. New work therefore cannot prevent a person from continuing an existing Ticket.
|
||||
|
||||
Other Inputs, such as CI results, wait for a normal slot. A launch waits in `working / queued` until a slot is free. Launches run concurrently, so a slow prepare command delays only its own Ticket.
|
||||
|
||||
### Execution variables
|
||||
|
||||
The prepare command and Executor instructions receive the same execution variables. The command reads environment variables; the Executor reads their values and descriptions in its prompt. None contains credentials.
|
||||
|
||||
| Variable | Registered by | Value |
|
||||
| --- | --- | --- |
|
||||
| `DSH_SUPERAGENT_DIR`, `DSH_SUPERAGENT_NAME`, `DSH_SUPERAGENT_TICKET` | Executor | The Ticket's directory, short name and id |
|
||||
| `DSH_SUPERAGENT_BLOCKED_COMMANDS` | Executor | Commands the Executor may not run |
|
||||
| `DSH_SUPERAGENT_GITHUB_REPOSITORY` | GitHub | The repository the Source watches, `owner/name` |
|
||||
| `DSH_SUPERAGENT_GITHUB_ACCOUNT`, `DSH_SUPERAGENT_GITHUB_COMMIT_EMAIL` | GitHub | The agent account and its commit email |
|
||||
| `DSH_SUPERAGENT_GITHUB_NUMBER`, `DSH_SUPERAGENT_GITHUB_PR_HEAD` | GitHub | The Issue or pull request that started the Ticket, and its head ref when it is a pull request |
|
||||
| `DSH_SUPERAGENT_FEISHU_BOT`, `DSH_SUPERAGENT_FEISHU_CHAT` | Feishu | The bot, and the chat that started the Ticket |
|
||||
|
||||
Ticket-specific values come from its directory and originating Trigger. Other values come from the current deployment settings. Only the panel can change execution environment settings; text sent to the main Session cannot change the configured shell command.
|
||||
|
||||
Workers reach GitHub through the agent account's `gh` and Feishu through the bot's `lark-cli`. Blocked commands fail when invoked by name.
|
||||
|
||||
These wrappers and directories establish defaults, not a security boundary. A worker can still execute a program by absolute path.
|
||||
|
||||
## Merging
|
||||
|
||||
Auto-merge is governed by the **Delivery requirements** prompt. The host has no merge-specific decision logic.
|
||||
|
||||
By default, the Executor never enables auto-merge as the agent account: GitHub attributes the merge to whoever enabled it. Delivery requirements must name another account's CLI for merging. Without one, people merge the pull request.
|
||||
|
||||
The default instructions allow auto-merge only for a pull request opened for the Ticket, when it is ready, the required approvals cover the head commit, and additions plus deletions total fewer than 200 lines. Before pushing another revision, the Executor withdraws auto-merge.
|
||||
|
||||
Reviews without a body, such as approvals, also arrive as Triggers and can wake an Executor waiting for review. These instructions govern the agent, not GitHub's merge rules. To enforce approvals, require them in branch protection and dismiss stale approvals. Omit the merge CLI or remove the auto-merge paragraph to require manual merging.
|
||||
|
||||
## How a Ticket ends
|
||||
|
||||
- **Ready without a pull request:** a `ready` Report ends the Ticket as completed.
|
||||
- **Nothing to do:** an Executor that finds no work for a person to act on, such as a request that does not reproduce or is superseded, reports `dropped`. The Ticket ends without a message, unless a matched rule asks for one, and a person's later message on its Threads resumes it.
|
||||
### Pull requests
|
||||
|
||||
A Ticket follows the pull request it started from once its Executor reports it. It also follows each pull request opened from its branch once reported by the Executor or found by the GitHub Source. Reopened pull requests are followed again.
|
||||
|
||||
The Ticket is done when none of the pull requests it opened remain open. If it opened none, all pull requests it follows must be closed. It then ends with the outcome of the last relevant close and keeps its Subscriptions, allowing a later human comment to resume it.
|
||||
|
||||
A close does not end the Ticket while resumed, retried or recovered work is unfinished. It also waits if the Executor acknowledged or answered a follow-up it owns but has not since made a non-progress Report or ended a turn after reading that message. A later `ready` Report ends the Ticket once the same pull-request completion rule is satisfied.
|
||||
|
||||
Chat Channels receive one final notice about the pull-request outcome. GitHub already displays the outcome on the pull request itself. Removing a Ticket's Subscription to a pull request also stops it following that pull request.
|
||||
|
||||
"Awaiting review" therefore means a `ready` Report with a pull request.
|
||||
|
||||
### Follow-ups and context Threads
|
||||
|
||||
The Executor decides human follow-ups on its Ticket's own Threads. It handles in-scope requests and hands back requests beyond its scope or for another Ticket. Once no Executor owns the message, it becomes a candidate for the main Session. The main Session may otherwise act on the follow-up only to stop work at the person's request.
|
||||
|
||||
An Input counts as read after the Session takes it into a step and subsequently reports or ends a turn. When a Ticket ends, unread messages that were not acknowledged, answered or handed back return to Eval, as described in [002 Authorization](002-authorization.md). Read messages left without action, such as thanks, do not.
|
||||
|
||||
Threads followed only for context still deliver messages, but do not give the Ticket ownership of human follow-ups or resume ended Tickets. Those messages can be assessed independently.
|
||||
|
||||
Sources poll subscribed Threads while a live Ticket subscribes, and for two weeks after the later of the last Trigger and the subscribing Tickets' end. Afterwards, messages arrive only through ordinary intake: GitHub notifications or Feishu live events.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
### Executor blocked or failed
|
||||
|
||||
When an Executor Session reports `blocked` or `failed`, or fails to start or errors, which records `failed`, the Ticket takes no automatic Input.
|
||||
|
||||
A passing failure of the model provider or the network, an HTTP 408, 429 or 5xx or a refused, reset or timed-out connection, is tried again first, three times with a growing backoff: a launch runs again, and a worker whose turn failed is told to go on; a failed prepare command is not tried again.
|
||||
|
||||
Once failed, CI and bot messages are recorded on it, but they are not delivered and do not wake the Session.
|
||||
|
||||
Its tools stay available, so a person can continue in the Session directly.
|
||||
|
||||
A launch that failed is not tried again on its own.
|
||||
|
||||
A person's message, a later Report that is neither `blocked` nor `failed`, or a retry ends the hold.
|
||||
|
||||
It is worked out from the stored Reports and Inputs, so a restart keeps it.
|
||||
### Retry
|
||||
|
||||
Available while an open Ticket is in `person / delivery`, `person / blocked` or `person / failed`: a new Assignment, which starts its worker again and takes the Inputs that wait, is the remedy for each.
|
||||
|
||||
Once a person's message reached a blocked or failed Ticket, the Executor has the message, so the Ticket shows as working, or as queued when a failed launch starts again, and offers no retry, unless delivering that message failed.
|
||||
|
||||
A retry ends the current Assignment and starts a new one in the same Executor Session, directory, branch and pull request.
|
||||
|
||||
The Executor model applies only when a Session is created; an existing Session keeps its model.
|
||||
### Restart
|
||||
|
||||
Running Assignments end with "host restarted" and new ones start with a recovery note that tells the Executor to check its directory, git history and GitHub before repeating any outside action, oldest Ticket first.
|
||||
|
||||
Assignments that are waiting, ready, blocked or failed stay as they are, unless an Input reached them after that Report, in which case they start again too.
|
||||
|
||||
Sessions, directories, Subscriptions, pending messages and the stored work below survive a restart, and so does the record of which messages each Executor Session took into its history, which a step does once it claimed them and before its model call: a message taken by a turn the restart cut off counts as read once a Report or the end of a turn follows, while one a step claimed but a cancel or error cut off before the history counts as unread.
|
||||
|
||||
A retry or a restart gives the new Assignment again the messages delivered to the Assignment it replaces that its Executor Session did not take, which a cancel or a cut-off step may have dropped; a copy still waiting in the Session, kept by a restart or persisted across a crash, is taken out before the new Assignment's first message, so the new delivery is the only copy.
|
||||
### Stored work
|
||||
|
||||
What dsh-superagent does after a decision, such as relaying a candidate, a follow-up or a report to the main Session, launching an Assignment or delivering an Input, is stored first and done afterwards.
|
||||
|
||||
A relay or a delivery that fails, such as when its Session cannot be reached, is retried after 2 seconds, then after a delay that doubles up to a minute; a launch that fails fails its Ticket, unless the failure was a passing one, as above.
|
||||
|
||||
Each kind of work reports an error among the overview's system problems only after one item failed three times in a row; a failed Input delivery shows on its Ticket instead.
|
||||
|
||||
Relays to the main Session keep their order, and so do notices; a failed one holds back the later ones of its kind.
|
||||
### Source failure
|
||||
|
||||
The GitHub Source reads each Issue, pull request and Thread on its own: a failed read keeps its error on its Thread, is read again on the next poll, and counts towards an error of the Source, which it reports after three failed polls in a row by default; any successful poll resets the count.
|
||||
|
||||
GitHub answers 404 also when the agent lost access to a private repository, so a 404 is retried.
|
||||
|
||||
A notified Issue or pull request whose reads keep failing is dropped a day after its first failed read, and at once when GitHub reports it as deleted; it then no longer fails the poll, its Thread keeps the error, and the Source's state names it.
|
||||
|
||||
Its read position stays where it was, so a later notification or a poll of its Thread reads what the failed reads missed.
|
||||
|
||||
The Feishu event consumer reports every failure, and polling of subscribed topics keeps each failure on its Thread.
|
||||
|
||||
A malformed item in a GitHub list or a Feishu page is skipped, and the Source's state counts it.
|
||||
### Channel failure
|
||||
|
||||
Outgoing messages are stored and sent as stored work, retried with backoff from 5 seconds, doubling up to 300.
|
||||
|
||||
A Ticket's messages to one conversation go out in the order they were queued, so its acknowledgement comes before its later answer or result: a message waits while an earlier one of the same Ticket to the same conversation has not gone out, until that one has failed three times in a row and shows as a problem.
|
||||
|
||||
Messages of other Tickets, and messages of no Ticket, do not wait for it.
|
||||
|
||||
A message with failed attempts is a system problem until it goes out or is withdrawn.
|
||||
|
||||
A message is dropped only when its Source no longer listens to the conversation, or when the Source answers that the conversation can never receive it; while its Source is off, it waits.
|
||||
|
||||
The Sources answer that for a deleted Issue or a locked conversation on GitHub, and for a recalled message or one Feishu no longer finds on Feishu.
|
||||
|
||||
A reply to a review comment that no longer exists goes to the pull request's conversation instead.
|
||||
|
||||
A dropped message keeps its record, with when and why it was dropped and the Ticket it was for, so it is never queued again; a dropped acknowledgement reads as dropped, not sent.
|
||||
### Pause processing
|
||||
|
||||
Stops dispatch, new Evals, Eval timeouts, Input delivery and replies; a launch already under way waits until processing resumes.
|
||||
|
||||
Sources keep recording Triggers, and a running Executor turn is not interrupted.
|
||||
|
||||
A person's message to an ended Ticket waits: the first pass after the pause resumes the Ticket, if its Mandate holds under the Policy then.
|
||||
|
||||
An Eval in progress does not expire during a pause; its deadline moves later by the pause's length.
|
||||
### Invalid configuration
|
||||
|
||||
A stored Source configuration that its kind rejects disables that Source instance.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 004 Prompts
|
||||
|
||||
This document explains what the agents read, what a team can edit, and when changes take effect. It builds on [000 Philosophy](000-philosophy.md) through [003 Execution](003-execution.md). Prompt text is written in English.
|
||||
|
||||
## What a team writes
|
||||
|
||||
A team can edit three team sections, add skills, and override every built-in system-prompt part. Most defaults live under `prompts/` and `skills/`. Tool descriptions, notices and cards remain in code and cannot be edited through prompt settings.
|
||||
|
||||
| Section | Holds |
|
||||
| --- | --- |
|
||||
| Communication | When to message people, how messages read, the line that begins every GitHub message (`This is by an automated agent, not a human` by default), how to handle reviews, and which ordinary accounts are review bots |
|
||||
| Ticket scope | When a Ticket goes on and when it stops with a draft |
|
||||
| Delivery requirements | Evidence in pull requests, the auto-merge condition with its line threshold, and the commit author; without one, the Executor commits as the agent account's no-reply address |
|
||||
| Team skills | Knowledge the agents load when they need it, such as an `identities` skill that says which Feishu user is which GitHub account |
|
||||
|
||||
The built-in text covers the roles, tool usage, host mechanics, authorization rules, and the GitHub and Feishu skills. It refers to the team sections by their headings instead of repeating them.
|
||||
|
||||
### Built-in parts and templates
|
||||
|
||||
Overrides use these keys:
|
||||
|
||||
| Parts | Keys |
|
||||
| --- | --- |
|
||||
| Shared instructions and roles | `runtime/shared`, `runtime/orchestrator`, `runtime/worker` |
|
||||
| Working instructions | `runtime/subscriptions`, `runtime/replies`, `runtime/dispatch`, `runtime/work`, `runtime/admission` |
|
||||
| Handoff instructions | `runtime/handoff/orchestrator`, `runtime/handoff/worker` |
|
||||
| Source skills | `skill/feishu`, `skill/github` |
|
||||
| Generated sections | `runtime/environment`, `runtime/policy`, `runtime/policy/none` |
|
||||
|
||||
The generated sections are editable templates. They must retain these placeholders:
|
||||
|
||||
- `runtime/environment`: `{variables}`, filled with execution environment lines.
|
||||
- `runtime/policy`: `{version}`, `{mode}` and `{rules}`, with one line per enabled rule.
|
||||
- `runtime/policy/none`: `{version}` and `{mode}`, used when no rule is enabled.
|
||||
|
||||
Editing a part changes the instructions the model receives. It does not change the gate, Mandates, tool checks or account wrappers, which remain enforced by code.
|
||||
|
||||
On an explicit human request, the main Session can also save overrides with `orchestrator_prompts_save`. If a save omits an override's `base`, it keeps the saved base. A removed part already present in the saved configuration is dropped on save; any other unknown part is refused.
|
||||
|
||||
An override records which built-in text it was based on. Upgrades preserve the team's text. If the default changes, the panel marks the part **Default updated** and shows the difference beside the team's text. The team can keep its text, use the new default or edit it. Parts without overrides automatically follow new defaults.
|
||||
|
||||
The main Session can read affected part names in `orchestrator_read prompts`, under `default_changed`.
|
||||
|
||||
## Assembly and precedence
|
||||
|
||||
The system prompt is assembled in this order:
|
||||
|
||||
1. Shared instructions and the role.
|
||||
2. The three team sections.
|
||||
3. Subscriptions and replies.
|
||||
4. Dispatch for the main Session, or work instructions for an Executor.
|
||||
5. `runtime/admission`, for the main Session only.
|
||||
6. Handoff instructions.
|
||||
7. `runtime/environment`.
|
||||
8. `runtime/policy`, for the main Session only.
|
||||
|
||||
Admission instructions come from `prompts/admission.md`. They describe proactive assessment and when an irrelevant candidate can be ignored from its card. `orchestrator_eval` returns context rather than repeating these instructions.
|
||||
|
||||
The environment section gives the main Session deployment-wide values and each Executor its Ticket-specific values. The Policy section is regenerated from settings on every model request. It contains the Policy version, accept mode, the explicit-request exemption, and enabled rules as `- <name> (<id>): <instruction>`, or a statement that none is enabled. It is last so a Policy change leaves the preceding prompt text unchanged.
|
||||
|
||||
Executors also read DeepSeek Harness's coding prompt and the repository's `AGENTS.md`; the main Session does not. An inferred Ticket adds a final note limiting its Executor to the matched rules. Assignments and incoming messages arrive inline as Trigger cards, with resource pointers only for truncated content.
|
||||
|
||||
Executors work unattended. Their tools exclude those that ask a person, present UI to them or change the profile, such as `ask_user_question`, `present` and `plugin_manager`.
|
||||
|
||||
From strongest to weakest:
|
||||
|
||||
1. Facts enforced by tools and the host: identities, permissions, tool argument checks, reply purposes, length limits. Prompt text cannot change them.
|
||||
2. Built-in and generated sections, as the team's overrides of them read.
|
||||
3. The process and communication in a proactive Ticket's matched rules, for that Ticket's Executor Session only.
|
||||
4. Team sections and team skills.
|
||||
5. Outside text: messages, comments, documents and quotes. It is data and never changes rules or authorization.
|
||||
|
||||
## Principles of the built-in text
|
||||
|
||||
These apply [000 Philosophy](000-philosophy.md) to the agents:
|
||||
|
||||
- **Separate roles.** The main Session only routes: it forwards the original Trigger and the routing facts, and makes no technical decisions, through one routing tool that takes the decisions on several waiting candidates in one call. A routing note has at most 30 words. The Executor reads the original sources, judges the scope, implements and verifies.
|
||||
- **The main Session does not wait.** Reports, follow-up notices, candidates and completed background jobs arrive automatically as messages. It ends the turn when nothing remains to do. A guard refuses `sleep`, shell timeouts over 30 seconds and job waits over 5 seconds, because a blocked step delays new candidates. These instructions replace DeepSeek Harness's own job-notification note in the main Session's complete prompt.
|
||||
- **Message only when a person must act or can use a result:** a result can be reviewed or used, a decision or resource is needed, or the work cannot be finished. Chat messages are one sentence by default, with a hard limit of 100 words.
|
||||
- **Executors decide their follow-ups.** Each Executor handles and acknowledges in-scope requests, and hands back requests beyond its Ticket or for another Ticket. A message becomes new work only after all its owners hand it back or end without taking it up. The main Session receives follow-up notices but may act on them only to stop work. Handed-back messages then arrive as candidates with the Executors' reasons.
|
||||
- **Coordinate through subscriptions and the main Session.** An Executor follows related Threads for context. The main Session can send a fact from another Ticket in a note of at most 100 words. Neither the Subscription nor the note grants authority.
|
||||
- **Attach only on an explicit reference.** A message must point to a Ticket's pull request, Issue, source message or acknowledgement, or to a recent change with exactly one matching Ticket. Topic similarity alone is not enough. Attachment subscribes the Ticket to the entire Thread; an Executor finding it unrelated uses `orchestrator_thread_unrelated` and does not answer there.
|
||||
- **Confirm concretely, once.** Before substantial work, the Executor confirms each new request and names the object and the expected result, as a reply under the original message.
|
||||
- **Ask through `human_action`.** The Executor puts one concrete question in its Report, and the main Session forwards it unchanged. If a person's message prompted the question, it names that message and the host asks there. Otherwise it asks in the Ticket's original conversation. Already authorized work can continue meanwhile.
|
||||
- **Honest delivery.** Opening a pull request is not readiness; a draft is marked unfinished; a UI change without a real before screenshot is not ready.
|
||||
- **A person's pause holds** across follow-ups, new Executor Sessions and restarts until a person lifts it. Automatic feedback never resumes paused work.
|
||||
- **Examples only show format.** An example sentence is not a fact the agent may claim.
|
||||
- **Handle reviews by outcome.** A fixed and verified comment is resolved silently; a declined one gets one sentence of reason. An automated reviewer's thread is resolved once it has an answer, and each automated reviewer gets at most three rounds per pull request.
|
||||
- **Yield to duplicate work.** When a person's branch or pull request already contains the Ticket's change, the Executor closes its own pull request with one sentence pointing to theirs.
|
||||
|
||||
## Versions
|
||||
|
||||
- Each save creates an immutable version containing the full configuration, revision number, predecessor, timestamp and origin (panel or tool). Restoring a version or defaults creates another version.
|
||||
- Saves name their base revision. A conflicting save is refused without discarding the draft.
|
||||
- Sessions read the latest version on every model request, including later steps in the same turn. Saving does not restart, cancel or wake a Session. The host records the revisions each Session used; an Executor can read any revision it worked under.
|
||||
- The deployment sets a total size limit, including overrides. Validation refuses unknown keys, reserved built-in skill names, unknown override parts and missing template placeholders. It never silently truncates text. An override identical to the default is removed so the part follows future defaults.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 005 Panel
|
||||
|
||||
Open **Superagent** in the DeepSeek Harness Web sidebar to supervise the agent. The panel prioritizes work that needs a person, then system health and history. This document builds on [000 Philosophy](000-philosophy.md) through [004 Prompts](004-prompts.md).
|
||||
|
||||
## Three questions
|
||||
|
||||
Opening the panel answers three questions, in this order:
|
||||
|
||||
1. Does anything need me now?
|
||||
2. Is the system healthy and doing the right things?
|
||||
3. What is the history of one piece of work, from Trigger through Eval to Ticket?
|
||||
|
||||
## Tabs
|
||||
|
||||
The tabs follow the workflow: **Overview**, **Source**, **Trigger**, **Eval**, **Execution**, and **Settings**. Until repository and account setup succeeds, Overview shows what is missing and links to the relevant setting.
|
||||
|
||||
| Tab | Shows |
|
||||
| --- | --- |
|
||||
| Overview | Tickets needing action, system problems, activity counts for the last 24 hours, and Executor load. Each block links to its detailed page |
|
||||
| Source | Source state, setup problems and non-zero counts. Filtered Triggers and dropped messages cover the same 24-hour window as Overview |
|
||||
| Trigger | Threads filtered by Subscribed, Evaluating, New Triggers in 24 h, or All Threads. Each Thread shows its Triggers newest first, Decisions and Subscriptions |
|
||||
| Eval | Decisions with their reason and matched rules |
|
||||
| Execution | Executor state, Pause processing, and Tickets filtered by Open, Pending, Working, Waiting for a Trigger or Ended. Ticket details show actions, authorization, waiting Inputs, Subscriptions and Assignments |
|
||||
| Settings | Sources, Eval, Execution, Prompts and Deployment. Each group includes its save history |
|
||||
|
||||
### Reading individual records
|
||||
|
||||
A dropped message links to its Ticket, or to its Thread when it has no Ticket. The Source page explains whether its conversation disappeared or the Source stopped listening to it.
|
||||
|
||||
A human follow-up still owned by an Executor is marked **Delivered to Ticket**, without an Eval. The Thread also shows hand-back reasons and context-only Subscriptions. **Evaluate again** can send that follow-up to Eval, or retry a Trigger with no effective judgement: one filtered by its Source, an unavailable Eval, or a delivered message with neither a non-stop Decision nor an Executor acknowledgement.
|
||||
|
||||
Ticket details show the originating Trigger, Decision and reason, scope and Policy version. Assignments and secondary details are folded below the immediate actions. **Retry** is available for failed Input delivery or a Ticket waiting for a person after a blocked or failed Report. Ended Tickets show outcomes such as **Pull request merged** or **Stopped by a person**.
|
||||
|
||||
## Settings groups
|
||||
|
||||
- **Sources:** one card per Source with its switch, fields and verified account facts. GitHub also offers receive-all switches for new Issues, pull requests, comments and default-branch CI. Polling and read limits are under Advanced.
|
||||
- **Eval:** the Policy version and rules, followed by **On each Source** and the Eval settings. Each Source tab contains its allowed list, membership sync, admission table and bot list. Sync runs at startup and on **Sync now**. The admission table distinguishes receiving scope, sender membership and whether the message addresses the agent. Eval settings select the accept mode, timeout and assessment model.
|
||||
- **Execution:** worker model, concurrency, directory root, prepare command and blocked commands. Clicking an available variable inserts it into the prepare command.
|
||||
- **Prompts:** **Edit** changes team sections, built-in parts and skills. **Full prompt** shows the assembled text and compares it with saved text or defaults. **History** lists versions. Edited built-in parts are marked changed and can be reset; templates list their required placeholders.
|
||||
|
||||
An upgrade that changes an overridden default marks it **Default updated** (默认内容已更新). The panel compares the old and new defaults above the team's text. If the old default was not recorded, it compares the team's text with the new default instead. Choose **Keep my text** or **Use the new default**. Overrides for removed parts are listed below the tab bar and dropped on the next save.
|
||||
- **Deployment:** startup problems with **Check again**, the state directory and machine limits. Saved limits apply without restart. The DeepSeek Harness Plugin Manager exposes the same page.
|
||||
|
||||
## Search
|
||||
|
||||
The header search finds Triggers by text, id, Feishu message id or a stored link, such as a GitHub comment URL. A Feishu link missing from the original message becomes searchable only after the host reads it during the current process lifetime.
|
||||
|
||||
Search also finds Threads by references such as `#12` or a GitHub URL, and Tickets by title or a matching Thread.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Focus on what matters.** Every element answers one of the three questions, or it is removed. A number appears only when it changes what the supervisor does.
|
||||
2. **Precise definitions.** Categories have checkable definitions, are mutually exclusive and cover every case, as the Ticket stages do. A label says whom a row waits for and what it needs, not an internal status code.
|
||||
3. **One place per concept.** Source state belongs in Source; Threads and Triggers in Trigger; Decisions in Eval; Tickets in Execution. Settings are grouped by the concept they configure. Overview summarizes these pages and links to them instead of becoming another detailed view.
|
||||
4. **Domain structure.** The panel uses the domain terms. Internal identifiers appear only in folded details, and names replace identifiers where they can be resolved.
|
||||
5. **Honest state.** A count equals the length of the list it links to, and no state contradicts another fact.
|
||||
6. **Quiet by default.** Healthy states take no extra space. Problems must be actionable; a one-off network error is not enough. Rows needing a person offer their action directly. Keep page explanations to one sentence and put further detail in the (?) tooltip. Destructive actions require confirmation.
|
||||
7. **Consistent controls.** Use DeepSeek Harness design tokens and control behavior. The same information should look the same throughout the panel. Each settings block has one save bar, visible only while changes exist, a save runs or a save fails.
|
||||
@@ -0,0 +1,274 @@
|
||||
# 006 Deployment
|
||||
|
||||
Use this guide to install, configure and operate dsh-superagent. It covers accounts, startup checks, upgrades and troubleshooting, building on [001 Domain model](001-domain.md) through [005 Panel](005-panel.md).
|
||||
|
||||
## What runs
|
||||
|
||||
- **A dedicated DeepSeek Harness profile**, called `superagent` in this guide. Create it from the Web profile template. It loads the bundle and serves its panel on `127.0.0.1`. Keep it separate from the profile you work in: the plugin changes process-wide `PATH` and git settings, and its process needs independent restarts and upgrades.
|
||||
- **An agent GitHub account.** The agent comments, pushes branches and opens pull requests as this account, through a GitHub CLI signed in only as that account.
|
||||
- **Optional:** a Feishu bot for chat requests.
|
||||
|
||||
Plugin configuration holds the state directory and machine limits. Use the panel for everything else: Sources for the repository and accounts, Policy for allowed lists, and Execution for worker directories and commands.
|
||||
|
||||
### Permissions and isolation
|
||||
|
||||
The bundle defines `dsh-superagent-worker` for Executors and `dsh-superagent-router` for the main Session. They appear in the DeepSeek Harness preset list but are intended only for dsh-superagent. Do not select them for personal Sessions or disable their configuration rows. The application needs them to create and resume its Sessions.
|
||||
|
||||
Opening a blank managed Session in the Web UI can select the profile's default preset. dsh-superagent restores its role-specific preset before the first turn.
|
||||
|
||||
Both presets run with `danger-full-access` as the operator's OS user and read messages from subscribed Threads. Account wrappers choose the agent's credentials by default; they do not isolate the process. A worker can execute programs by path and read the operator's home directory. For isolation, use a dedicated OS account containing only the agent's credentials.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22.19+ or 24+, pnpm, git 2.32+, Python 3.8+ and tmux
|
||||
- GitHub CLI (`gh`) 2.48 or later. Source CLI fields must point to `gh` or `lark-cli` wrappers; the host relies on their subcommands and output formats.
|
||||
- A repository on github.com; GitHub Enterprise Server is not supported. To create a checkout per Ticket, keep a local clone from which the prepare command can add worktrees. Install `git-lfs` if that repository uses Git LFS.
|
||||
- A model provider configured for DeepSeek Harness. dsh-superagent uses the profile's default model unless the Eval or Execution settings name another.
|
||||
- For Feishu: `lark-cli` (tested with 1.0.90) and a Feishu or Lark app with a bot
|
||||
|
||||
## Accounts
|
||||
|
||||
### Agent GitHub account
|
||||
|
||||
1. Create a GitHub user account for the agent and give it write access to the repository. A GitHub App cannot be the agent, because dsh-superagent reads the account's notifications.
|
||||
2. Give it a GitHub CLI that uses only its own credentials:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/bin
|
||||
cat > ~/.local/bin/gh-agent <<'EOF'
|
||||
#!/bin/sh
|
||||
GH_CONFIG_DIR="$HOME/.config/gh-agent" exec gh "$@"
|
||||
EOF
|
||||
chmod +x ~/.local/bin/gh-agent
|
||||
gh-agent auth login
|
||||
gh-agent api user --jq .login
|
||||
```
|
||||
|
||||
Sign in as the agent account; the last command prints its login. dsh-superagent reads the login the same way.
|
||||
|
||||
### Feishu bot (optional)
|
||||
|
||||
1. Create a self-built app with the bot capability. Grant it the permissions to receive message events, read and send chat messages, and read chat members, then publish the app version.
|
||||
2. Give the bot its own `lark-cli` configuration, restricted to the bot identity, and a wrapper:
|
||||
|
||||
```sh
|
||||
LARKSUITE_CLI_CONFIG_DIR="$HOME/.lark-cli-dsh-superagent" lark-cli config init --app-id <cli_…> --app-secret-stdin
|
||||
LARKSUITE_CLI_CONFIG_DIR="$HOME/.lark-cli-dsh-superagent" lark-cli config strict-mode bot --global
|
||||
cat > ~/.local/bin/lark-bot <<'EOF'
|
||||
#!/bin/sh
|
||||
export LARKSUITE_CLI_CONFIG_DIR="$HOME/.lark-cli-dsh-superagent"
|
||||
exec lark-cli "$@"
|
||||
EOF
|
||||
chmod +x ~/.local/bin/lark-bot
|
||||
```
|
||||
|
||||
The first command reads the app secret from standard input. Use a separate configuration directory: `lark-cli` allows one profile per app id, and your personal configuration may already contain a user login for that app.
|
||||
|
||||
The host refuses a CLI that can act as a user. It reads the app id, Feishu/Lark brand and bot `open_id` from the bot configuration.
|
||||
|
||||
Both configurations share the keychain entry for the app secret. To remove only the bot configuration, delete its directory; do not run `lark-cli config remove`, which would affect the shared secret.
|
||||
|
||||
## Install
|
||||
|
||||
Run this block once, on a machine that has no `superagent` profile yet:
|
||||
|
||||
```sh
|
||||
npm install -g @deepseek-ai/dsh@0.2.1-rc.3
|
||||
git clone <this repository> dsh-superagent && cd dsh-superagent
|
||||
pnpm install && pnpm build
|
||||
dsh superagent --from-default-profile web --dump-config > /dev/null
|
||||
dsh plugin --profile superagent add "link:$PWD"
|
||||
```
|
||||
|
||||
The pinned version above is public; other versions must satisfy `engines.dsh` in `package.json`.
|
||||
|
||||
The fourth command creates `~/.dsh/profiles/superagent` from the DeepSeek Harness Web template. The fifth installs this bundle. Set `DSH_HOME` to relocate the default `~/.dsh` directory.
|
||||
|
||||
Run the profile-creation command only for a new installation. If the profile already exists, that command replaces it and deletes its `cordis.patch.yml`, including machine configuration. Do not use it for upgrades.
|
||||
|
||||
## Machine configuration
|
||||
|
||||
Copy [examples/profile.cordis.patch.yml](../examples/profile.cordis.patch.yml) to `~/.dsh/profiles/superagent/cordis.patch.yml`, replacing that file, and replace every `<placeholder>`. For Feishu, also enable the `dsh-superagent-feishu` entry there. The `webserver` entry sets the panel's port.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `stateDir` | yes | Absolute path: the database, the lock, the command shims in `worker-bin`, the agent's git configuration and, by default, the Ticket directories in `work`. Back it up before upgrades |
|
||||
| `promptMaxChars`, `admissionContextMaxPages`, `admissionContextTimeoutMs` | no | The largest saved prompt configuration, and how many Thread pages one assessment reads within what time |
|
||||
|
||||
After the first start, edit machine limits in **Settings → Deployment** or the DeepSeek Harness Plugin Manager. Changes apply without restart. `stateDir` cannot be changed there. Invalid values or unknown configuration keys prevent startup; the log identifies the key.
|
||||
|
||||
## Start and stop
|
||||
|
||||
```sh
|
||||
scripts/dsh-superagent start
|
||||
scripts/dsh-superagent status
|
||||
```
|
||||
|
||||
`start` runs `dsh --profile superagent` in tmux session `dsh-superagent`, or `dsh-<profile>` for another profile. It waits up to one minute for the runtime to become ready or report a setup requirement. On a first run it prints the missing setup information. It refuses duplicate instances, including ones started outside this script.
|
||||
|
||||
`stop` and `restart` request graceful shutdown and wait up to five minutes for Executors. They never force-kill the process or touch other tmux sessions.
|
||||
|
||||
`status` prints process ids, health and setup problems. For a different profile or port, use `--profile` and `--url`, or set `DSH_SUPERAGENT_PROFILE` and `DSH_SUPERAGENT_URL`.
|
||||
|
||||
`status`, `start` and `restart` exit with one of four codes:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| 0 | The service runs normally |
|
||||
| 1 | It does not run, runs twice, does not answer, or its runtime reports an error; a restart may fix it |
|
||||
| 2 | It runs, but a part other than the runtime, such as a Source or a Policy sync (`sync:<source id>`), reports an error; a restart does not fix it |
|
||||
| 3 | It runs and waits for setup; a restart does not fix it |
|
||||
|
||||
A Source that is still starting counts as normal operation. So does a Feishu bot that setup could not check.
|
||||
|
||||
`status` returns failed parts and their details under `errors`, and setup requirements under `setup`. `start` and `restart` print those problems as lines; they exit 1 if the service never comes up. Only an intentional `stop` pauses automatic recovery.
|
||||
|
||||
Each profile stores logs and control files under `.artifacts/`:
|
||||
|
||||
- Startup logs: `dsh-superagent-<profile>-<date>-<time>-<pid>.log`, where the pid belongs to the script. The newest 20 are kept.
|
||||
- Control files: `control/profiles/<profile>/`, containing the stop marker, lock and process id.
|
||||
|
||||
Open the URL printed in the log, then choose **Superagent** in the sidebar.
|
||||
|
||||
### Hourly check
|
||||
|
||||
Create the hourly check in the DeepSeek Harness profile you normally use, not in `superagent`. It must keep running when the agent service is down. Ask an agent in that profile to create an hourly schedule using this prompt, with the checkout path filled in:
|
||||
|
||||
```text
|
||||
Check dsh-superagent: run `scripts/dsh-superagent --profile superagent status` in <checkout>. Exit 0: do nothing and say nothing. Exit 2: do not restart; tell me each part under `errors` with its detail, unless you already told me about the same ones. Exit 3: do not restart; tell me what `setup` says setup needs, unless you already told me. If it reports manually_stopped: true, leave it stopped. Otherwise (exit 1) run `scripts/dsh-superagent --profile superagent --respect-stop restart`, which exits the same way: 0 means it recovered, so say nothing; 2 and 3 are reported as above; if it exits 1 or fails otherwise, read the newest .artifacts/dsh-superagent-superagent-*.log and the Troubleshooting section of docs/006-deployment.md, fix what you can, and tell me what was wrong and what you did.
|
||||
```
|
||||
|
||||
`--respect-stop` preserves an intentional stop. Only `stop` writes the marker; `start` clears it. `restart` writes no marker, so the check can recover a restart whose shutdown timed out.
|
||||
|
||||
## Settings
|
||||
|
||||
Setup must succeed before polling or work starts. Overview lists missing requirements and links to their settings. A settings change runs setup again. If you fix something outside the application, such as CLI authentication, choose **Check again**.
|
||||
|
||||
1. **Sources.** Set the GitHub repository (`owner/name`) and agent CLI, for example `/home/me/.local/bin/gh-agent`. Requests normally arrive through the agent account's notifications. A new Issue or comment mentioning the account arrives on the next poll; adding a mention by editing an existing Issue body does not. Post a new comment instead. For Feishu, set the bot CLI, chats, each chat's receive-all or mentions-only mode, and whether to receive direct messages.
|
||||
2. **Policy.** The two example rules can be edited or removed. Under **On each Source**, add the GitHub accounts and Feishu users who may ask for work; until a list has someone, no request can become a Ticket. With Feishu, a chat can be chosen to sync the list from, at startup and on **Sync now**.
|
||||
3. **Execution.** The directory root and a prepare command. To give each Ticket a checkout of the repository:
|
||||
|
||||
```sh
|
||||
git -C /home/me/src/widgets fetch --no-tags "https://github.com/$DSH_SUPERAGENT_GITHUB_REPOSITORY.git" "${DSH_SUPERAGENT_GITHUB_PR_HEAD:-HEAD}:refs/dsh-superagent/$DSH_SUPERAGENT_TICKET" &&
|
||||
git -C /home/me/src/widgets worktree add -b "agent/$DSH_SUPERAGENT_NAME" "$DSH_SUPERAGENT_DIR" "refs/dsh-superagent/$DSH_SUPERAGENT_TICKET"
|
||||
```
|
||||
|
||||
List personal CLIs, such as one signed in to your own account, as blocked commands.
|
||||
4. **Prompts.** Set the team sections: the GitHub disclosure line, review bots, the commit author and the auto-merge condition.
|
||||
|
||||
### Management API
|
||||
|
||||
The panel uses a local HTTP API that agents can also call. `SETUP.md` uses it to save settings, and the service script reads `GET state`.
|
||||
|
||||
Send requests to `http://127.0.0.1:<port>/orchestrator/api/` with `x-orchestrator-request: 1`. The host must be `127.0.0.1:<port>` or `localhost:<port>`. Missing headers or an `Origin` from another site receive 403.
|
||||
|
||||
API saves have the panel's permissions. They can change execution environment and setup account fields, unlike saves made through the main Session's tools.
|
||||
|
||||
Agents may rely on these routes and fields. The other routes in `src/app/api.ts` serve the panel and change with it.
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET state` | What every page reads: `setup`, with `problem` and `feishuProblem`, each `null` or a setup problem with `place`, `code`, `values` and `detail`, and `accounts` once setup resolved them; `health`, an entry per part, below, with `state` (`starting`, `ready`, `paused` or `error`), `detail` and `at`; `load`, the Executor Sessions `running`, the Tickets `queued` and `maxWorkers`; `paused`; and `masterId` |
|
||||
| `GET settings` | `blocks`: `policy`, `eval` and `executor`, each with its `value` and its `revision`; `sources`: the Source instances, each with `id`, `kind`, `installed`, its block's `value`, `{"enabled": …, "config": {…}}`, and `revision`, its kind's form, and its setup `problem` when it has one; `policyVersion`; and `environment`, the execution environment's `root` and `variables` |
|
||||
| `GET sources` | Each Source's status: `state` (`missing`, `off`, `error`, `setup`, `starting`, `paused` or `ready`), the `health` of its parts, its setup `problem`, the last day's `stats`, the Triggers it `filtered` in that day, and the messages it `dropped` in that day, newest drop first, each with its `channel`, `preview`, `ticket` (`id` and `title`) when it was for one, its `thread` when the store has it, and `dropped.at` and `dropped.reason`, `channelGone` or `channelIgnored` |
|
||||
| `PUT settings/<block>` with `{"value": …, "revision": n}` | Saves one block: `saved`, the block's new `revision`, `policy_changed` and `restarting`. `value` is the whole block, as `GET settings` holds it; a missing key takes its default |
|
||||
| `POST executor/pause` with `{"paused": true}` or `false` | Pauses or resumes processing: `paused` |
|
||||
| `POST setup/check` | Runs setup again by restarting dsh-superagent: `restarting: true`. Refused when setup has nothing to fix |
|
||||
| `POST policy-sync/<source id>` | Syncs that Source's allowed list now: `policy`, the Source's part as saved |
|
||||
| `GET prompts`, `PUT prompts` with `{"config", "baseRevision"}` | Reads the prompt configuration: `current`, with its `revision` and `config`, `defaults`, `sections`, the team sections in the order Sessions read them, `skillName`, the pattern of a team skill's name, `builtin`, the names a team skill cannot take, and `skillPrefix`, which Sessions load a skill's name under. Or saves it: the new `revision` |
|
||||
| `POST tickets/<id>/retry`, `POST tickets/<id>/stop` | Retries or stops a Ticket, as the panel does |
|
||||
|
||||
Health entries are grouped by responsibility:
|
||||
|
||||
| Entry | Reports |
|
||||
| --- | --- |
|
||||
| `runtime` | The application itself |
|
||||
| `models` | Sessions waiting on a model-budget retry |
|
||||
| `evals` | Unavailable Evals from the last hour with no later judgement or dismissal |
|
||||
| `assessment`, `launch`, `delivery`, `relay`, `notice`, `reply` | Stored work described in [003 Execution](003-execution.md); delivery counts waiting work, while reply covers Channel messages |
|
||||
| `worker` | Executor Session event handling; appears after its first failure |
|
||||
| `github`, `feishu`, `history` | Source polling, events and history recovery |
|
||||
| `sync:<source id>` | Allowed-list synchronization |
|
||||
|
||||
A refusal has a status and `error`, its English text:
|
||||
|
||||
- 400: an invalid value (`issues`, each with its `path`), a failed account check (`setup`), a fixed refusal (`code` and `values`), or a body that is not JSON or is too large.
|
||||
- 404: something that does not exist.
|
||||
- 409: a stale revision.
|
||||
- 500: anything else.
|
||||
|
||||
The blocks:
|
||||
|
||||
- `source:github`, `source:feishu`: `{"enabled": true, "config": {…}}`. GitHub's config has `repository` and `cli`, and the switches `observeIssues`, `observePulls`, `observeComments` and `observeCi`, off by default, which receive every new Issue, new pull request and comment in the repository and the default branch's CI; Feishu's has `cli`, `chats` as `[{"chatId": "oc_…", "name": "…", "listen": "mentions"}]` (or `"all"`), `directMessages`, and `otherChats` as `"none"` or `"mentions"`.
|
||||
- `policy`: `{"rules": […], "sources": {"github": {"allowed": [{"id": "<login>"}], "bots": [{"id": "dependabot[bot]"}]}, "feishu": {"allowed": [{"id": "ou_…", "name": "…"}], "syncChat": "oc_…", "admission": {"oc_…": {"unlistedAddressed": "explicit"}}}}}`. `bots` lists the bots whose messages may become inferred candidates. An `admission` row is keyed by chat id, `other` or `direct` for Feishu, and by `issues`, `pulls`, `comments` or `ci` for GitHub; an unset cell takes its default (see [002 Authorization](002-authorization.md)).
|
||||
- `eval`: `mode`, `timeoutSeconds` and `model`.
|
||||
- `executor`: `model`, `maxWorkers`, `directory`, `prepare` and `blockedCommands`. The processing pause is a setting of its own, `paused`, which `POST executor/pause` sets.
|
||||
|
||||
Changing a repository or CLI runs the corresponding setup check first. A failed check returns 400 with details in `setup`. A successful account change, or any save while setup is pending, returns `restarting: true`. The API is temporarily unavailable while the application restarts.
|
||||
|
||||
```sh
|
||||
api=http://127.0.0.1:3082/orchestrator/api
|
||||
rev=$(curl -s -H 'x-orchestrator-request: 1' $api/settings | jq '.sources[] | select(.id == "github") | .revision')
|
||||
curl -s -X PUT -H 'x-orchestrator-request: 1' -H 'content-type: application/json' $api/settings/source:github \
|
||||
-d "{\"value\": {\"enabled\": true, \"config\": {\"repository\": \"acme/widgets\", \"cli\": \"$HOME/.local/bin/gh-agent\"}}, \"revision\": $rev}"
|
||||
```
|
||||
|
||||
## Setup checks
|
||||
|
||||
Setup uses the DeepSeek Harness subprocess service, as worker shells do. It removes environment variables whose names contain KEY, PASSWORD, SECRET or TOKEN.
|
||||
|
||||
Setup verifies the GitHub repository and the agent's push access, writes the worker environment, and checks that git uses only the agent credentials for github.com. A Feishu setup failure disables only the Feishu Source.
|
||||
|
||||
The process environment that setup writes:
|
||||
|
||||
- **Command shims** in `<stateDir>/worker-bin`, first on `PATH`: `gh` runs the agent's CLI; `lark-cli` runs the bot's CLI; blocked commands fail. A shim removes its own directory from `PATH` before it runs its target.
|
||||
- **git configuration** in `<stateDir>/worker-gitconfig`: replaces the operator's global and system git configuration. It selects the agent CLI as the github.com credential helper, rewrites GitHub SSH addresses to HTTPS and defines Git LFS filters. Personal git settings such as an HTTP proxy do not apply. No commit identity is set here, git never prompts for credentials, and `GH_TOKEN` and `GITHUB_TOKEN` are removed.
|
||||
|
||||
A file in `worker-bin` or `worker-gitconfig` that dsh-superagent did not write stops setup. A Ticket directory inside a git repository whose own configuration adds credentials fails the Ticket.
|
||||
|
||||
## Try it
|
||||
|
||||
From an allowed GitHub account, open an Issue or post a comment containing `@<agent login> <request>`. Within a polling interval (one minute by default), Overview should show a Ticket. The agent acknowledges the request, works in its own Session and directory, and delivers in the original conversation. For Feishu, mention the bot in a configured chat.
|
||||
|
||||
## Upgrade
|
||||
|
||||
1. Wait until no Executor is in the middle of a turn.
|
||||
2. Back up `<stateDir>/orchestrator.sqlite`.
|
||||
3. Run `git pull && pnpm install && pnpm build`.
|
||||
4. Run `scripts/dsh-superagent restart` and inspect its status.
|
||||
|
||||
A restart ends running Assignments and starts replacements with recovery notes. Team text and overridden prompt parts are retained; parts without overrides follow the new defaults. Review parts marked **Default updated** after upgrading.
|
||||
|
||||
A database migrated by a newer version cannot be opened by an older one. Rollback therefore requires the pre-upgrade backup. After upgrading DeepSeek Harness itself, restart the running instance so the panel loads matching artifacts.
|
||||
|
||||
### Agent presets
|
||||
|
||||
The worker and router presets move shell commands to the background after 5 seconds. A Session cannot load without its preset. Keep the `dsh-superagent-worker` and `dsh-superagent-router` rows in `cordis.patch.yml` while their Sessions are in use; otherwise resume fails with `agent-preset/not-found`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Setup problems appear on the overview with a link to the fix, in the panel's language; the log and the API show the English text below.
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
| --- | --- |
|
||||
| `The agent GitHub CLI is not set.` | Set the agent's CLI on the GitHub card |
|
||||
| `The agent GitHub CLI was not found.` | Set the wrapper's absolute path, or a command name on `PATH` |
|
||||
| `The agent GitHub CLI cannot read its account` | Sign the wrapper in with `auth login` |
|
||||
| `@… cannot read …` or `@… cannot push to …` | Give the agent account write access to the repository |
|
||||
| `The GitHub Source names no repository.` | Set the repository, `owner/name`, on the GitHub card |
|
||||
| A Ticket fails with `The prepare command failed: …` | The output names the cause, such as a wrong clone path; fix the command, then retry the Ticket |
|
||||
| `git does not read the github.com credential configuration dsh-superagent writes` | Install git 2.32 or later |
|
||||
| A Ticket fails with `The git configuration of the repository in … sets …` | Remove the named credential or `http.*.extraheader` settings from the clone's `.git/config` |
|
||||
| `… was not written by dsh-superagent` | Move the named file out of `<stateDir>/worker-bin` or `<stateDir>/worker-gitconfig`, or choose another `stateDir` |
|
||||
| `The bot lark-cli allows user identities` | Point the Feishu Source at a bot-only configuration, as in Feishu bot above |
|
||||
| `The bot lark-cli does not work.` or `The bot identity of app … is …` | Check the configuration's app id and secret |
|
||||
| `The Feishu bot is not a member of the chat.` | Add the bot to the chat in the chat's settings; the next attempt succeeds |
|
||||
| `status` exits 2 | A part reports an error, which a restart does not fix: `errors` names each part, as listed under Management API, with its detail; each part clears its own entry once it works again |
|
||||
| `status` exits 3 | Setup waits: `setup` says what is missing, and the overview links to the setting that fixes it |
|
||||
| `status` exits 1 | The service does not run, runs twice, does not answer, or its runtime reports an error; the output and the newest log say which |
|
||||
| `… timed out after 90 s`, `… was stopped by …`, `… exited with code …` or `… could not run: …` | A CLI failed without printing why; run the named program by hand as the operator's user to see its own message |
|
||||
| The panel says dsh-superagent is not answering | It is restarting, or did not start because of its configuration; the DeepSeek Harness log names the cause |
|
||||
| `dsh-superagent configuration has keys it does not use` (log) | Remove the named keys; the message says where each value lives now |
|
||||
| The log says the plugin is incompatible with the running dsh | Install a DeepSeek Harness version within the `engines.dsh` range |
|
||||
| The panel shows "Failed to load plugins" after a DeepSeek Harness upgrade | Restart the running instance |
|
||||
| Nothing happens after a mention | Check that the author is on the Source's allowed list, the Source is on, and processing is not paused. On GitHub, a mention added by editing an Issue's body is not received; mention the agent in a comment |
|
||||
@@ -0,0 +1,617 @@
|
||||
# 007 Architecture
|
||||
|
||||
This is the developer guide to the implementation. It maps the concepts in [001 Domain model](001-domain.md) through [006 Deployment](006-deployment.md) to classes, interfaces and files. Start with the runtime and Engine, then use the module and test tables as a code index.
|
||||
|
||||
## Core classes and glue
|
||||
|
||||
Core classes own domain concepts and external boundaries behind small interfaces. Glue translates tool arguments, HTTP requests, Source payloads and DeepSeek Harness events into calls to those classes, then turns results into cards, views and messages.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
plugins[Source plugins] -->|SourceContext| engine
|
||||
tools[Session tools] --> engine
|
||||
http[Management routes] --> engine
|
||||
engine[Engine] -->|Repositories| store[Store]
|
||||
engine -->|Policy| policy[Policy]
|
||||
engine -->|SourceScope| registry[SourceRegistry]
|
||||
engine -->|Assessor| assessor[MasterAssessor]
|
||||
engine -->|Executor| executor[DshExecutor]
|
||||
assessor -->|decide| engine
|
||||
engine -->|Outbox| outbox[Outbox]
|
||||
executor -->|assignmentStarted, inputsDelivered, report| engine
|
||||
settings[Settings] -->|policyChanged| engine
|
||||
assessor & executor & sessions[Sessions] & outbox -->|enqueue| effects[Effects]
|
||||
effects --> store
|
||||
```
|
||||
|
||||
`Runtime` (`src/app/runtime.ts`) constructs the application, supplies dependencies, and owns startup and shutdown. `Engine` constructs its own internal parts. The Executor and Assessor call back into the Engine, so Runtime gives them getters rather than requiring construction in a circular order.
|
||||
|
||||
### Runtime
|
||||
|
||||
`Runtime` is the composition root; it contains no domain rules. Its constructor locks the state directory, opens the Store and builds each component. If construction fails, it closes the Store and releases the lock.
|
||||
|
||||
Startup registers management routes and runs Setup. After setup resolves, it installs Session and Executor hooks, ensures the main Session exists, starts Policy syncs, resumes interrupted Assignments, and starts passes and effects. `ready` marks completion of startup.
|
||||
|
||||
Shutdown cancels turns, waits for passes, effects and agents, restores the process environment, closes the Store and releases the lock. Source plugins inject Runtime as the `orchestrator` service through the narrow `SourceHost` interface.
|
||||
|
||||
| Part | Module | Does |
|
||||
| --- | --- | --- |
|
||||
| Setup | `src/app/setup.ts` | Resolves the repository and the accounts by probing their CLIs the way worker shells run them, writes and applies the workers' environment and command shims, and keeps the setup problems; Settings checks a changed account through it, and restarts through it, which runs setup again |
|
||||
| Passes | `src/app/passes.ts` | One pass at a time, every two seconds and whenever a command asks: the due effects, then `Engine.evaluateDue`; a Source's Trigger reaches the Engine through it |
|
||||
| Outbox | `src/app/outbox.ts` | The Engine's `Outbox` port, and the only writer of the receipts: `send` keeps a message's receipt and queues its `reply` effect in one transaction, and the effect's handler sends it through `SourceRegistry.send`; a message waits while its Source is off, and is dropped when the conversation is gone or not listened to |
|
||||
| Notices | `src/app/notices.ts` | Converts domain events and worker activity into main-Session messages. `eventNotice` handles domain notices; `reportRelay` forwards questions immediately, keeps ordinary progress on its Ticket, and relays other reports; `followupRelay` names the Tickets that own a human follow-up. Relays arriving during a turn are delivered together at its next step through the DeepSeek Harness inbox |
|
||||
|
||||
### Engine
|
||||
|
||||
`Engine` (`src/core/engine.ts`) is the only writer of Triggers, Threads, Decisions, Tickets and Assignments. It receives these ports from `src/core/ports.ts`: `Repositories`, a current-Policy getter, `SourceScope`, `Assessor`, `Executor` and `Outbox`. Optional dependencies are `prepareTicket`, `now` and `emit`.
|
||||
|
||||
All Engine methods are synchronous. Each command changes its aggregates in one transaction. `evaluateDue` uses a separate transaction for each Eval or Ticket it changes. Ports enqueue external work inside the transaction; adapters perform it after commit. Invalid requests throw `UserError`.
|
||||
|
||||
The Engine exposes one entry point and delegates to five internal parts. It owns each command's transaction. Commands spanning parts, such as `attach`, `evaluateDue` and `policyChanged`, call the parts in sequence.
|
||||
|
||||
Each part extends `EnginePart` (`src/core/part.ts`), which provides options and shared reads. Dependencies between the parts flow in one direction:
|
||||
|
||||
| Part | Responsibility | Depends on |
|
||||
| --- | --- | --- |
|
||||
| `Intake` | A Source's Trigger: stored once with its disposition, recorded on its Threads and their correlations, fanned out to the subscribed Tickets and, unless it is a person's message that reached one, queued for Eval on its primary Thread | Tickets |
|
||||
| `Evals` | The Evals of Threads: batching, the gate's strength, actor and basis with the Trigger that gave them (`primary`), the deadline from the candidate's delivery and its shift by a pause, the requeue of an expired Eval, candidates, replays and hand-backs, the requeue on a Policy change, and the Decision each ends with: accept, stop, ignore or unavailable | Tickets |
|
||||
| `Tickets` | The Ticket lifecycle: creation from an accept with its Mandate, Subscriptions and first Assignment, the Threads it follows and their closes (`followsDone`), attached Threads and Threads followed for context, Inputs and notes, resume and the messages a resume refuses, retry, restart, stop and end, the Subscriptions it drops, and the notice of a Policy change to the Tickets it bears on | Replies |
|
||||
| `Assignments` | What the Executor and its workers report: an Assignment started, Inputs reached it or failed to, a restart interrupted it, and a worker's Report with its question for a person | Tickets, Replies |
|
||||
| `Replies` | Channel writes through the `Outbox`: the acknowledgement, the answer, the result and the attention message, each by the rule that allows it | Nothing |
|
||||
|
||||
Commands use imperative names such as `stopTicket`; facts reported by adapters use past-tense names such as `assignmentStarted`. The query `candidate` uses a noun.
|
||||
|
||||
Callers receive only their role's `Pick` of the Engine: `EngineForSources`, `EngineForMainSession`, `EngineForPeople`, `EngineForSettings` or `EngineForExecutor`.
|
||||
|
||||
| Role | Method | Effect |
|
||||
| --- | --- | --- |
|
||||
| Sources | `receive(trigger)` | Stores a Trigger once and answers `stored`, `duplicate`, or `filtered` when no Source receives it; fans it out to the Tickets subscribed to its Threads, where a person's message resumes an ended Ticket at once unless processing is paused, and queues it for Eval on its primary Thread, unless it is a person's message that reached a Ticket, which stays that Ticket's |
|
||||
| Sources | `bindThread(ticketId, thread, own)` | Makes an open Ticket follow one of the Source's Threads, such as a pull request's, `own` when the Ticket opened it; a closed Thread bound again is open again. The Threads' closes, not a `ready` Report, end the Ticket |
|
||||
| Sources | `threadClosed(thread, outcome, at)` | A Thread closed: each open Ticket that follows it records the close, and ends once it is done with the Threads it follows (`followsDone`): no Thread it opened is open, or, when it opened none, every Thread it follows closed. It ends `merged` or `closed` as the latest of those closes did, unless work resumed after the close has not reported `ready`, or its Executor took up, by acknowledging or answering it, a person's follow-up it decides after its latest Report other than `progress`, or the end of its last turn; a `dropped` Report then ends it as the close did |
|
||||
| Main Session | `candidate(triggerId)` | The Eval that holds a Trigger, as its assessor sees it |
|
||||
| Main Session | `candidateDelivered(evalId)` | The Eval's candidate reached the main Session: its deadline counts from now |
|
||||
| Main Session | `decide(triggerId, outcome)` | Records an Eval Decision, or a direct Decision for an explicit request. Accept creates the Ticket, Mandate, Assignment, Subscriptions and acknowledgement atomically. An inferred candidate ignored from its card records `ignoredFromCard` before the supplied reason. While `followupOwners` still owns a human follow-up, only stop is allowed |
|
||||
| Main Session | `attach(ticketId, triggerId, note?)` | Routes a Trigger to a Ticket it points at, whose Executor then decides it as a follow-up, and ends the Eval that holds it, whose other Triggers wait for a fresh Eval; an ended Ticket resumes under its Mandate at once unless processing is paused. A follow-up an Executor still decides is refused, as is one routed back to the Ticket that handed it back |
|
||||
| Main Session | `requestStatus(ticketId)` | Asks a waiting Executor Session for its status, and answers whether it asked |
|
||||
| Main Session | `note(ticketId, text)` | Gives a Ticket's Executor a fact from other work as a text Input, which grants nothing |
|
||||
| Main Session, Executor | `reply(request)` | Queues a message for the Channel its purpose names: `acknowledgement`, `answer`, `result` or `attention` |
|
||||
| People | `stopTicket(ticketId, reason?)` | A person's stop, which withdraws the Mandate |
|
||||
| People | `retryTicket(ticketId)` | Ends the stalled Assignment of a Ticket that waits for a person after a `failed` or `blocked` Report, or whose Input delivery failed (`canRetry`), and starts a new one |
|
||||
| People | `reevaluate(triggerId)` | Queues a stored Trigger for a fresh Eval |
|
||||
| People | `unsubscribe(thread)` | Drops every Subscription on a Thread, and the Tickets that followed it follow it no more; each keeps deciding the follow-ups it received there |
|
||||
| People | `dismiss(decisionId)` | Dismisses the problem an `unavailable` Decision shows, which the Decision records |
|
||||
| Executor | `assignmentStarted(ticketId, assignmentId)` | An Assignment runs; its waiting Inputs go out |
|
||||
| Executor | `inputsDelivered(ticketId, assignmentId, inputIds)` | Inputs reached the Assignment's Executor Session, where each waits for a step to take it |
|
||||
| Executor | `inputsRead(ticketId, inputIds)` | The Executor Session took the messages that carry these Inputs into its history (a `user/message` Session event), from which its model has them; a step that claimed them and was cancelled or failed before does not count. Each counts as read once a Report or the end of a turn follows, also after a restart, and its Ticket's end does not let go of it; an Input delivered before reads were recorded counts as read once a Report or the end of a turn followed its delivery |
|
||||
| Executor | `turnEnded(ticketId, assignmentId)` | A turn of the Executor Session ended, also after a Report, once nothing runs for it in the background or as it hands off to a person. What a step took before, it read (`inputsRead`) |
|
||||
| Executor | `deliveryFailed(ticketId, assignmentId, error)` | Delivering Inputs failed; the Ticket shows it until a delivery succeeds, a new Assignment starts or the Ticket ends |
|
||||
| Executor | `report(ticketId, report)` | Records a Report and its question for a person; `dropped` ends the Ticket, and `ready` completes one that follows no Thread, and ends one done with the Threads it follows as they ended |
|
||||
| Executor | `markUnrelated(ticketId, thread, reason)` | Drops only that Ticket's Subscription to an attached Thread it found unrelated, and hands back, with the reason, the follow-ups there that the Ticket decides |
|
||||
| Executor | `followForContext(ticketId, thread)` | Subscribes an open Ticket to a related Thread for context: its messages reach the Ticket but lend it no follow-up strength and do not resume it |
|
||||
| Executor | `handback(ticketId, triggerId, reason)` | Records that the Ticket hands back a Trigger that reached it, with the reason its candidate carries, and queues it for a fresh Eval once no other Ticket decides it, if this Ticket did; it answers whether it queued it. A Ticket hands back each Trigger once and keeps its Subscription |
|
||||
| Executor | `assignmentInterrupted(ticketId, assignmentId)` | An Assignment was running when the host stopped: one interrupted mid-work, or one that received an Input after its latest Report, starts again with a recovery note and the Inputs delivered to it that its Executor Session did not take, as a retry does; one that had reported since keeps its Session. The DshExecutor reports each at start (`resume`), oldest Ticket first, and queues again a launch that waits |
|
||||
| Passes | `evaluateDue()` | Unless processing is paused: starts the Evals of the Threads whose queued Triggers wait, one per Thread with the Triggers queued meanwhile as one batch; queues the Triggers of an Eval past its deadline again, twice in a row, and records it `unavailable` the third time; and resumes the ended Tickets a person's message reached during a pause, if their Mandates still hold. An Eval whose candidate has not reached the main Session does not expire. While processing is paused, no Eval expires, and once it resumes each deadline moves later by the pause's length |
|
||||
| Settings | `policyChanged(from, to, effects)` | Tells each live Ticket the change bears on how (`policyEffects` in `src/policy.ts`: a rule it was accepted under changed or no longer applies, its requester may no longer start work, or proactive work is no longer accepted), and restarts the Evals in progress under the new version |
|
||||
|
||||
The Engine keeps batching, authorization, fan-out, resume and attention lifecycle rules private. Several shared predicates make these rules consistent:
|
||||
|
||||
- `waitsForPerson` (`src/core/model.ts`): the latest Assignment reported `blocked` or `failed`, and no human message has arrived since. Automatic Inputs and failed launches remain held while this is true.
|
||||
- `followupOwners`: the Tickets still responsible for a human follow-up received on their own Threads. A Ticket can be live or ended without a human stop and waiting to resume. Handing back the message ends that ownership.
|
||||
- `settled`: a Decision resolved the request; stop, unavailable, routed and refused-stop Decisions do not count.
|
||||
- `ticketStage` (`src/core/stage.ts`): implements the stage table in [003 Execution](003-execution.md).
|
||||
|
||||
When a Ticket ends, unread follow-ups return to Eval unless another Ticket still owns them or another disposition settled them. Acknowledged, answered and handed-back messages are excluded. For older records without acknowledgement dispositions, the outbox receipt supplies that fact. A final Report also covers messages waiting when the Assignment began, including the message that resumed it. The message that stopped a Ticket is not automatically re-evaluated.
|
||||
|
||||
After a change, the Engine emits an event from `DomainEvents` in `src/core/ports.ts`:
|
||||
|
||||
`trigger.received`, `eval.started`, `decision.recorded`, `ticket.created`, `ticket.resumed`, `ticket.ended`, `assignment.started`, `assignment.ended`, `thread.subscribed`, `thread.unsubscribed`, `trigger.requeued`, or `policy.changed`.
|
||||
|
||||
Runtime turns `ticket.ended`, `trigger.requeued` and `thread.unsubscribed` into notices. A human `trigger.received` delivered to Tickets becomes a follow-up relay. `pullEndNotices` sends one chat notice, using a stable id, when a Ticket ends merged or closed. Other events currently have no consumer.
|
||||
|
||||
### Model
|
||||
|
||||
`src/core/model.ts` holds the domain types, and `src/core/stage.ts` the Ticket stages. A Decision records why as `reason`, a list of `Reason`s (`src/core/reasons.ts`): each a `code` with its `values` and, for one Trigger of a batch, its `triggerId`.
|
||||
|
||||
The core's codes, such as `timeout`, `policyChanged`, `routed` and `startedTicket`, are `REASONS`, with the English that agents read through `reasonText`; a gate or a Source scope gives its own, such as `not_a_member`; and `said` carries words an assessor or a person wrote. The panel words each code by `reason.<code>`, and a Trigger a Source filtered records the Source's code the same way.
|
||||
|
||||
A Ticket keeps each fact once. It is open while it has no `ended`, which records when, by whom, why and with what outcome it ended; what it last said is its latest Assignment Report (`latestReport`); and its Executor Session is named after it (`workerSessionId` in `src/executor/dsh.ts`).
|
||||
|
||||
Its fields are typed: `origin`, the admissible Trigger that gave its Eval's batch its strength and the batch itself (`triggerId`, `triggerIds`); `execution`, what the Executor chose (`name`, `directory` and `assignment`); `delivery`, the last failed Input delivery (`error`, `at`), until a delivery succeeds, a new Assignment starts or the Ticket ends; and `follows`, the Threads a Source made it follow, each `{thread, own, closed?}`: `own` when the Ticket opened it, and `closed` with `at` and `outcome` once it closed. Tickets alone writes `follows`; the GitHub Source reads its pull requests with `pullsOf` and `pullOf` (`src/sources/github-pull.ts`).
|
||||
|
||||
An Input that carries a Trigger records whether a person wrote it, which `waitsForPerson` reads. A Thread also lists in `context` the subscribed Tickets that follow it only for context, and counts in `expired` the Evals of its queued Triggers that expired in a row; an Evaluation records its `primary` Trigger and when its candidate was `delivered`.
|
||||
|
||||
A Trigger's disposition records the Tickets it reached, by fan-out or attachment, a person's requeue, each Ticket that handed it back, with the reason, and each Ticket whose Executor acknowledged or answered it.
|
||||
|
||||
A Mandate's `basis` (`MandateBasis`) records how the gate admitted its Trigger. Revalidation reads the receiving `source`, `scope` and `row`, the listed `sender`, matched `rules`, and `via`:
|
||||
|
||||
| `via` | Admission basis |
|
||||
| --- | --- |
|
||||
| `addressed` | An allowed sender addressed the agent |
|
||||
| `followup` | A message arrived on a Thread with Tickets |
|
||||
| `admission` | An admission-table cell; `observation` marks an inferred candidate |
|
||||
| `bot` | A listed bot produced an inferred candidate |
|
||||
|
||||
Trigger and Thread `meta` belong to the Source. The core does not interpret them.
|
||||
|
||||
### Policy
|
||||
|
||||
`src/policy.ts` defines settings schemas, authorization and assessment rules. `settingsPolicy(settings, scope)` returns a fresh, stateless `Policy` on each use:
|
||||
|
||||
- `version`, `paused` and `evalTimeoutMs` describe its current state.
|
||||
- `gate(trigger, thread, tickets)` returns admission strength and basis.
|
||||
- `revalidate(mandate)` checks whether an existing Mandate still holds.
|
||||
- `admitsInferred(rules)` permits proactive acceptance only in auto mode with enabled matching rules.
|
||||
|
||||
`assessmentOutcome(assessment, settings, strength)` converts the model's assessment to an Engine outcome. It requires a judgement for every enabled rule, settled direction, a matching rule and evidence of intent. Outside auto mode, an inferred accept becomes a “would accept” record.
|
||||
|
||||
`policyEffects(tickets, old, now, scope)` identifies which open Tickets a change affects and how. `Engine.policyChanged` notifies only those Tickets. `policyVersion` hashes rules, accept mode and the Sources' Policy parts, including bot lists.
|
||||
|
||||
### Store
|
||||
|
||||
`Store` (`src/app/store.ts`) owns one SQLite database. Runtime acquires the directory lock before opening it, preventing a second process from migrating or writing the same state.
|
||||
|
||||
Its interfaces are grouped by purpose:
|
||||
|
||||
- **Domain repositories:** `atomic`, plus `triggers`, `threads`, `tickets` and `decisions`, each with `get`, `save` and list queries. Indexed working sets are `threads.active()`, `tickets.open()`, `tickets.active()` and `read.subscribedThreads`.
|
||||
- **Read queries:** `read` serves views, tools and Source polls through methods such as `threadTriggers`, `triggerStats`, `search` and `droppedReplies`. These methods never write.
|
||||
- **Source metadata:** `threads.describe` and `threads.patchMeta` update Source-owned Thread metadata.
|
||||
- **Settings:** `settings`, `saveSettings`, `blockRevision` and `settingsHistory`. Settings and prompts use numbered `Versions` (`src/app/versions.ts`).
|
||||
- **Outbox receipts:** `replyStatus` and `receipts`, written only by Outbox. Receipt operations are `get`, `waiting`, `behind`, `add`, `settle` and `withdraw`. `behind` checks whether an earlier message for the same Ticket and conversation is waiting with fewer than three failures. A withdrawn message settles with `channelGone` or `channelIgnored`. Read queries expose failed and dropped replies through `failingReplies`, `droppedReplies` and `droppedSince`.
|
||||
- **Typed key-value records:** `get`, `set`, `delete` and `keys`, using entries from `KV`.
|
||||
|
||||
`afterCommit` runs an action only after the transaction commits, never after rollback. Runtime reads records in their stored shape; the schema 11 migration converts older supported records at startup.
|
||||
|
||||
### Effects
|
||||
|
||||
`Effects` (`src/app/effects.ts`) is the durable side-effect queue. A command calls `enqueue(kind, key, payload)` inside its transaction; execution begins only after commit.
|
||||
|
||||
Each kind/key pair has one row. Enqueuing the same waiting key replaces its payload and makes it due immediately without changing queue order. `handle(kind, run, options)` registers one handler per kind. It processes due rows oldest first, up to the concurrency limit.
|
||||
|
||||
Failures retry with exponential backoff. Throwing `Later` defers work without counting a failure, for example when no worker slot is available. `start` begins processing after setup, `drain` stops it during shutdown while preserving pending rows, and `pending`/`idle` expose queue state.
|
||||
|
||||
| Kind | Handler | Does | At once | Retry |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `assessment` | MasterAssessor | Steers an Eval's candidate card into the main Session's turn, then tells the Engine it was delivered, which starts the Eval's deadline | 4, none while paused | 2 s doubling to 60 s |
|
||||
| `launch` | DshExecutor | Launches a pending Assignment: prepare command, Executor Session, assignment message | The free worker slots, none while paused | A passing failure of the model provider or the network runs again after 5 s doubling to 300 s, three times; any other failure, and a failed prepare command, fails its Ticket. One held by a pause or by full worker slots tries again after 5 s |
|
||||
| `delivery` | DshExecutor | Hands a Ticket's waiting Inputs to its running Assignment, in order | 4, none while paused | 2 s doubling to 60 s; one that waits for a worker slot tries again after 2 s |
|
||||
| `relay` | Sessions | Steers a worker's report or question, or a person's follow-up, into the main Session's turn | 1, in queue order | 2 s doubling to 60 s |
|
||||
| `notice` | Sessions | Injects a notice into the main Session without waking it | 1, in queue order | 2 s doubling to 60 s |
|
||||
| `reply` | Outbox | Sends a message for a Channel through its Source, after the earlier messages of its Ticket to its conversation that have not failed three times; withdrawing the message cancels it | 1, none while paused | 5 s doubling to 300 s; one whose Source is off, or behind an earlier message, tries again after 5 s |
|
||||
|
||||
Each kind has a health entry under its name. It reports an error once one of its effects failed three times in a row, except `delivery`, whose failures show on their Ticket instead. Relays keep their order, and so do notices; a failed one holds back the later ones of its kind.
|
||||
|
||||
### Sources
|
||||
|
||||
Sources extend the host through `SourceKind`, defined in `src/sources/kind.ts`. A kind declares its identity, title, description, configuration `schema` and `fields`, derived `facts`, and `health` entries. Optional behavior includes `scope`, a `channel` (`allowed`, `send`, `link`), `threadContext`, `find`, `policy`, `environment` and `refreshLabels`.
|
||||
|
||||
`SourceRegistry` (`registry.ts`) owns registered kinds and stored instances, and implements the Engine's `SourceScope`. Registration claims health entries on `HealthBoard`; duplicate claims and reserved `sync:` names are refused. A SourceContext writes only through its registration's claimed entries, so reports after unregistration do nothing.
|
||||
|
||||
Registry configuration methods include `kind`, `instances`, `instance`, `validate`, `validatePolicy`, `policyPart`, `channelAllowed`, `declarations`, `environments`, `find` and `scope`. `find` asks each kind that supports search.
|
||||
|
||||
Generic code accesses Source behavior through the registry. `send(message, commands)` calls the kind's Channel and returns `sent`, `gone` or `waiting` if the kind is not installed. `link` resolves its Channel link. `threadContext` pairs a kind's reader with its configuration, or returns nothing when the kind has no reader. Invalid stored configuration disables the instance.
|
||||
|
||||
Plugins receive `SourceHost`: the registry and `context(kind)`. The kind itself uses a narrower `SourceContext`, built by `sourceContextOf` in `src/app/source-context.ts`.
|
||||
|
||||
That context supplies deployment and commands, setup readiness (`ready`, `configured`, `problem`), configuration, `receive`, `atomic`, and typed `state`. It also exposes Thread reads and metadata writes, Ticket reads and binding/closure commands, Trigger reads, `notify` through Outbox, and `report` for declared health entries.
|
||||
|
||||
### DshExecutor
|
||||
|
||||
`DshExecutor` (`src/executor/dsh.ts`) provides one Session and directory per Ticket. `start` and `deliver` enqueue `launch` and `delivery` effects. `stop` cancels the turn through `Store.afterCommit`, so a rolled-back end does not stop the worker.
|
||||
|
||||
`prepare(ticket, outcome)` supplies the Engine's `prepareTicket` hook: short name, directory and `ticketExecution` assignment text. `workerSessionId(ticket.id)` derives the Session id.
|
||||
|
||||
`WorkerSlots` reserves capacity before a launch or a delivery that wakes an idle worker. A human message can use one slot beyond `maxWorkers`, and wakes have priority over launches. `activeCount` supplies panel load.
|
||||
|
||||
Transient provider and network failures get three retries. Launches use effect backoff; failed turns receive continuation messages after 15, 30 and 60 seconds. `inputMessage` renders each Input from current Ticket state, and `mandateInstructions` adds the matched-rule limits for inferred work.
|
||||
|
||||
`install` subscribes to Session events; `dispose` unsubscribes. A turn end normally reports waiting, except after readiness or while human action is required. Errors report failed. At startup, `resume` reports interrupted Assignments through `assignmentInterrupted` and requeues pending launches that do not wait for a person, oldest Ticket first.
|
||||
|
||||
### MasterAssessor
|
||||
|
||||
`MasterAssessor` (`src/app/assessment.ts`) lets the main Session assess proactive candidates. `request` queues an assessment effect; `release` drops cached state when its Eval ends.
|
||||
|
||||
Its `context`, `read` and `decide` methods serve `orchestrator_eval`, `orchestrator_eval_read` and `orchestrator_eval_decide`. They provide bounded Thread context through `SourceRegistry.threadContext`, repository context, and decision submission. A kind without a context reader supplies only the Trigger and batch.
|
||||
|
||||
`assessmentOutcome` converts the model's answer for `Engine.decide`. Evals checks inferred acceptance against the current Policy again.
|
||||
|
||||
### Settings
|
||||
|
||||
`Settings` (`src/app/settings.ts`) reads and writes revisioned settings blocks. `current` and `view` read them; `save(block, value, revision, origin)` validates a save; `pause(paused, origin)` changes the separate `paused` setting.
|
||||
|
||||
Every save declares its origin: `panel`, `tool` or `sync`. Only `panel` can change the execution environment or fields listed in `SETUP_FIELDS` (`src/deployment.ts`).
|
||||
|
||||
A Policy-version change calculates `policyEffects`, records which Tickets were notified, and calls `Engine.policyChanged`. `syncable` and `sync(id)` refresh a Source's Policy part. `refreshLabels(id)` refreshes display names.
|
||||
|
||||
### Prompts
|
||||
|
||||
`Prompts` (`src/app/prompts.ts`) stores configurations as `Versions`. `current`, `version` and `history` read them; `validate`, `preview` and `save(value, baseRevision, source)` check and save them. `compose` and `skills` build Session text.
|
||||
|
||||
`builtInParts` lists override keys, roles, default text and required template placeholders. Each override stores team `text` and a `base`: the `builtInBase` key of its original default. Saves preserve that default under `KV.prompts.builtIn`. `defaultChanged` then reports changed defaults, their old text when available, and removed parts.
|
||||
|
||||
Validation inherits an omitted base from the saved override, drops removed parts already present in saved data, and rejects blank text or other unknown keys. This allows the save tool to round-trip a configuration it read. `environmentSection` and `policySection` substitute template values once, using own keys only. Configurations without overrides use defaults.
|
||||
|
||||
### Sessions
|
||||
|
||||
`Sessions` (`src/app/sessions.ts`) manages DeepSeek Harness Sessions for both roles. `ensure` creates a Session with its role's model. `install` follows agent events and registers prompts, skills and tools for managed Sessions.
|
||||
|
||||
`prompt` delivers once per request id. `relay` and `notify` enqueue main-Session messages handled by the `relay` and `notice` effects. `src/app/notices.ts` defines their content; `delivery` checks whether a message arrived.
|
||||
|
||||
Each model request binds the Session to the current prompt revision. The revisions it used are stored durably: `promptVersion` identifies the active one and `mayRead` restricts which versions a worker can inspect. Registrations are tracked in one map per agent.
|
||||
|
||||
`status` and `promptVersion` serve prompt pages. Saves go through `Prompts.save` and notify the main Session. `modelRetry` exposes model-budget retry state.
|
||||
|
||||
### Glue
|
||||
|
||||
| Glue | Translates |
|
||||
| --- | --- |
|
||||
| `src/app/runtime.ts` | The composition root: builds the parts with the interfaces they use, registers the routes and the Source host, and starts and stops dsh-superagent |
|
||||
| `src/app/notices.ts` | Domain events and worker reports into notices and relays for the main Session |
|
||||
| `src/app/tools/main.ts`, `src/app/tools/executor.ts` | Tool arguments of the main Session and the Executor Sessions into calls of the core classes, and their results into cards |
|
||||
| `src/app/http.ts`, `src/app/views/`, `src/app/api.ts` | HTTP requests into calls, and Store reads into the panel's views; `api.ts` types every route for the panel |
|
||||
| `src/app/resources.ts`, `src/app/links.ts`, `src/app/cards.ts` | Resource names into reads, source messages into links, records into compact cards for models |
|
||||
| `src/sources/github*.ts`, `src/sources/feishu*.ts`, `src/sources/poll.ts` | GitHub and Feishu payloads into Triggers and Thread facts, and messages into CLI calls |
|
||||
| `src/deployment.ts`, `src/setup.ts`, `src/infra/` | Setup probes and problems, CLI transport, the agent's process environment |
|
||||
|
||||
## Dependency rules
|
||||
|
||||
- **Explicit, narrow dependencies.** Each class receives an interface that names what it uses, mostly as `Pick` types of other classes: `ExecutorHost`, `AssessorHost`, `SessionsHost`, `SettingsHost`, `SetupHost`, `PassesHost` and `OutboxHost` for the core classes, and `HttpHost`, `MainToolsHost`, `ExecutorToolsHost`, `ResourceHost`, `LinkHost` and `SourceContextDeps` for the glue, with the Engine as the `Pick` of the role that calls it. The Runtime passes itself only behind such an interface. A Source plugin injects the `orchestrator` service, which its type names as a `SourceHost`: the registry and `context(kind)`; its kind's code sees only its `SourceContext`.
|
||||
- **What may import what.** `src/core` imports nothing outside itself apart from Node's hashing, and does no I/O. `src/sources` imports nothing from `src/app`. The panel imports only types from the host side, and the plain values of `src/admission.ts`, `src/limits.ts`, `src/setup.ts`, `src/navigation.ts`, `src/sources/host.ts`, `src/core/errors.ts`, `src/core/reasons.ts` and the filter lists of `src/app/api.ts`.
|
||||
- **One writer per state.** Each kind of state has one writer, and the others read it or ask that writer.
|
||||
|
||||
| State | Writer |
|
||||
| --- | --- |
|
||||
| Triggers, Threads, Decisions, Tickets, Assignments | Engine |
|
||||
| A Source's read positions, and the meta it keeps on its Threads (`label`, `url`, `facts` and its own keys) | That Source, through `SourceContext` |
|
||||
| Settings and their history | Settings |
|
||||
| Prompt versions | Prompts |
|
||||
| Effect rows | Effects |
|
||||
| Outbox receipts | The Outbox: the Engine's messages and Sources' `notify` go through its `send`, and its `reply` effects mark them |
|
||||
| A health entry | The part that claimed it on the `HealthBoard` (`src/infra/health.ts`), which refuses a second claim: `runtime` the Runtime, which Passes report through, `evals` the Runtime after each pass, `models` Sessions, each effect kind Effects, `worker` the DshExecutor, a Source kind's entries the SourceRegistry for that kind's Source, `sync:<id>` Settings |
|
||||
|
||||
A Source changes a Ticket only through `SourceContext.tickets.bindThread` and `threadClosed`, which call the Engine.
|
||||
- **Requests and facts.** A method that asks for something is named by an imperative verb and its object, such as `stopTicket` or `bindThread`, and may be refused; a method that records what an adapter did is named in the past tense, such as `assignmentStarted` or `deliveryFailed`. Each host receives the Engine as the role type it needs.
|
||||
- **Typed fields, and Source data under the Source.** The code reads typed fields of Tickets and Mandates. A Source keeps nothing on a Ticket: which of its Threads a Ticket follows is the Ticket's `follows`, and what it keeps about a Trigger or a Thread is in their `meta`.
|
||||
- **Fixed refusals.** A refusal a person or an agent can cause is a `UserError(code, values)` from `USER_ERRORS` in `src/core/errors.ts`. The management API answers a `missing` code with 404 and the others with 400, and the panel words each code in its language. Setup problems are `SetupError` codes (`src/setup.ts`) and fixed health texts `HealthCode`s (`src/sources/host.ts`), worded the same way.
|
||||
### Source-specific dependencies
|
||||
|
||||
Some generic modules still refer directly to GitHub or Feishu:
|
||||
|
||||
- Outbox applies the Feishu chat word limit.
|
||||
- `src/app/links.ts` resolves Feishu message links and `repliedTo`. It and Runtime read settings with `feishuSettings`.
|
||||
- `src/app/resources.ts` reads the `feishu/` and `github/` originals; `src/app/cards.ts` uses `pullOf` for Ticket cards.
|
||||
- Runtime sends `pullEndNotices` for merged or closed Tickets.
|
||||
- Executor tools bind reported pull requests with `readPull` and `claimPull`, using the GitHub context exposed by Runtime.
|
||||
- MasterAssessor reads repository files with `readRepositoryPath`.
|
||||
- Sessions and Prompts register built-in skills only for GitHub and Feishu. Migration converts their schema-10 records.
|
||||
- Setup probes only GitHub and Feishu account fields (`SETUP_FIELDS`). Only those Sources receive account setup problems through `SourceContext.problem`.
|
||||
|
||||
|
||||
## Modules
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/index.ts` | The main plugin entry: reads the machine settings, starts the Runtime, and gives it the restart that a save of a Source's account uses |
|
||||
| `src/deployment.ts` | The plugin `Config` and `Machine`; `resolveDeployment` and `resolveFeishuBot` read the repository and the accounts from the Source settings, each CLI run as Executor shells run it; `SETUP_FIELDS` lists the Source fields setup reads. The code names no person, account or organization |
|
||||
| `src/setup.ts` | Setup problems: `SetupError`, with a code, the place that fixes it and the English text the log, the main Session and the panel share |
|
||||
| `src/policy.ts` | The Policy: the settings schemas by block, the Policy version, the gate and Mandate revalidation |
|
||||
| `src/admission.ts` | The admission table's values, each cell's options and defaults, shared with the panel |
|
||||
| `src/limits.ts` | The limits of the values a person sets, which the settings schemas check and the panel's fields offer |
|
||||
| `src/kv.ts` | `KV`: every family of `kv` records with its key, its value's type and its owner, and the `Key` and `Family` types the Store and `SourceContext.state` take |
|
||||
| `src/navigation.ts` | Opens a DeepSeek Harness Session from the panel once the host's catalog lists it |
|
||||
| `src/core/model.ts` | The domain types, among them the typed Ticket and `MandateBasis`, with `threadKey`, `ticketOutcome`, `reached`, `followupOwners`, `settled`, `waitsForPerson`, `liveTicket`, `runningAssignment`, `latestAssignment` and `latestReport` |
|
||||
| `src/core/ports.ts` | The ports: `Repositories`, `Policy`, `SourceScope`, `Assessor`, `Executor`, `Outbox`, and the domain events |
|
||||
| `src/core/engine.ts` | The Engine, the core's entry point, and the role interfaces `EngineForSources`, `EngineForMainSession`, `EngineForPeople`, `EngineForSettings` and `EngineForExecutor` |
|
||||
| `src/core/part.ts` | `EnginePart`, the base of the Engine's parts, and `EngineOptions` |
|
||||
| `src/core/intake.ts` | `Intake`: Triggers from Sources, to their Tickets and, unless a person's message reached one, to Eval |
|
||||
| `src/core/evals.ts` | `Evals`: the Evals of Threads and their Decisions |
|
||||
| `src/core/tickets.ts` | `Tickets`: the Ticket lifecycle, the Threads a Ticket follows, Subscriptions and Inputs |
|
||||
| `src/core/assignments.ts` | `Assignments`: the Executor's facts and its workers' Reports |
|
||||
| `src/core/replies.ts` | `Replies`: the Engine's Channel writes |
|
||||
| `src/core/stage.ts` | `ticketStage` and `canRetry`: the Ticket stages of [003 Execution](003-execution.md) |
|
||||
| `src/core/errors.ts` | `UserError` and `USER_ERRORS` |
|
||||
| `src/core/reasons.ts` | `Reason`, `REASONS` and `reasonText`: why the core recorded a Decision or set a Trigger aside, by code |
|
||||
| `src/core/ids.ts` | `key`: ids derived from their inputs, equal for equal inputs |
|
||||
| `src/core/memory.ts` | `MemoryRepositories`: the Store's repositories in memory, in the Store's order and with its nested rollback, for domain tests |
|
||||
| `src/core/index.ts` | The core's exports |
|
||||
| `src/app/runtime.ts` | The Runtime, the composition root, with its start and stop |
|
||||
| `src/app/setup.ts` | Setup, and `setupChanged`, whether a save changes a field setup reads |
|
||||
| `src/app/passes.ts` | Passes, the pass scheduler |
|
||||
| `src/app/outbox.ts` | The Outbox: the Engine's `Outbox` port, the receipts and the `reply` effects that send them |
|
||||
| `src/app/notices.ts` | The main Session's notices and relays: `eventNotice`, `reportRelay`, `resultRelay` and `followupRelay` |
|
||||
| `src/app/store.ts` | The Store, and `Receipt`, a message of the outbox |
|
||||
| `src/app/store-read.ts` | `storeReads`: the Store's read queries for the views, the tools and the Sources' polls, which never write |
|
||||
| `src/app/migration.ts` | The schema 11 migration: one step from schema 10 that writes every stored record in its current shape |
|
||||
| `src/app/effects.ts` | Effects |
|
||||
| `src/app/versions.ts` | `Versions`: a record kept as numbered snapshots with a pointer to the current one, for the settings and the prompts |
|
||||
| `src/app/cache.ts` | `TextCache`: texts kept in memory within a size, the least recently used dropped first |
|
||||
| `src/app/settings.ts` | Settings |
|
||||
| `src/app/settings-history.ts` | `policyHistory`: each saved settings revision with what it changed from the one before |
|
||||
| `src/app/prompts.ts` | Prompts, `builtInParts`, the built-in parts a team may override, and `promptParts`, the order of a role's prompt sections |
|
||||
| `src/app/context.ts` | The built-in prompt text, read from `prompts/`, and the transcript entries Sessions read |
|
||||
| `src/app/skill-files.ts` | Packaged text: `promptFile`, which joins a file's one-sentence lines into paragraphs, and the bundled skills |
|
||||
| `src/app/sessions.ts` | Sessions |
|
||||
| `src/app/delivery.ts` | `AgentDelivery`: delivers a message to a DeepSeek Harness agent once per request id, also across a restored Session |
|
||||
| `src/app/retry-health.ts` | `RetryHealth`: which Sessions wait on a model budget retry, for the `models` health entry |
|
||||
| `src/app/role-models.ts` | Resolves the Eval and Executor models, and seeds a new Session with its role's model |
|
||||
| `src/app/assessment.ts` | The MasterAssessor, the assessment schema and the window `orchestrator_eval` answers |
|
||||
| `src/app/execution-environment.ts` | The execution environment: its variables, a Ticket's values, the directory root and the lines of the prompt section, which `src/app/prompts.ts` fills into its template |
|
||||
| `src/app/source-context.ts` | `sourceContextOf`: each Source kind's `SourceContext` |
|
||||
| `src/app/links.ts` | `sourceLink`, the page of a Trigger's message, and `candidateCard` |
|
||||
| `src/app/cards.ts` | Compact cards of Triggers, Tickets, Threads and Decisions for models, the closed set of ticket card states `TICKET_STATES`, and `textPage` |
|
||||
| `src/app/resources.ts` | `RESOURCES` and `readResource`: what `orchestrator_read` reads, for each role |
|
||||
| `src/app/tools/tool.ts` | The `Tool` type, `readTool`, the `orchestrator_read` of both roles, and `AcknowledgementSchema` |
|
||||
| `src/app/tools/main.ts` | The main Session's tools |
|
||||
| `src/app/tools/executor.ts` | An Executor Session's tools |
|
||||
| `src/app/http.ts` | The `/orchestrator/api` routes: the same-origin check, request limits and one status per kind of failure |
|
||||
| `src/app/api.ts` | `Api`, every route with its parameters, query, body and response, and the filters of the lists; it imports only types, so the panel bundles it |
|
||||
| `src/app/views/text.ts` | The two forms of a view's text: `preview`, plain text on one line, and `markdown` without HTML; `plain` and `readable` |
|
||||
| `src/app/views/records.ts` | The rows the views share: Triggers, Tickets, Threads, Decisions and Channel links, and `ownersOf`, the owners of follow-ups as one request reads them |
|
||||
| `src/app/views/tickets.ts` | The Ticket lists by stage, a Ticket's page, the Mandate basis facts and the Executor's load |
|
||||
| `src/app/views/threads.ts` | The Thread lists, a Thread's timeline, the search and the Decision list |
|
||||
| `src/app/views/sources.ts` | The Source status: each Source instance's state, its last day's counts, and what it filtered and dropped in that day |
|
||||
| `src/app/views/settings.ts` | The settings page's view: each block with its revision, each Source's settings with its kind's form, and the execution environment |
|
||||
| `src/app/views/overview.ts` | The overview: Tickets that need a person, problems, the last day's flow and the Executor's load |
|
||||
| `src/executor/dsh.ts` | The DshExecutor, `WorkerSlots`, `ticketExecution`, `inputMessage` and `mandateInstructions` |
|
||||
| `src/executor/background.ts` | A worker's background work in DeepSeek Harness, the shell jobs its Session owns (`ctx.jobs`) and its subagents (`ctx.subagents`): `runningJobs`, `untilQuiet`, `killJobs`, `collectJobs`, `stopSubagents`, `stopBackground`, `until`, and `cancelled`, the workers whose turn a stop of their subagents cancelled |
|
||||
| `src/infra/commands.ts` | `Commands`, the CLI transport: `run`; `api` and `pages`, limited to the configured repository and validated; `lark` as the bot; `git`. `CliError` carries the code a command's output reports, such as a GitHub HTTP status, and a command that prints nothing fails with how it ended, never with its arguments; `validItems` skips and counts the malformed items of a list |
|
||||
| `src/infra/environment.ts` | The agent's process environment: command shims, the git configuration and credential helper, `runAsWorker`, and the checks of the environment and of a Ticket's directory |
|
||||
| `src/infra/paths.ts` | The package's root, where packaged files are |
|
||||
| `src/infra/text.ts` | Word counts and the chat message limit, `fill` and `sameName` |
|
||||
| `src/infra/health.ts` | The `HealthBoard`, every part's health entry, each claimed by its one writer |
|
||||
| `src/sources/kind.ts` | The `SourceKind` protocol |
|
||||
| `src/sources/registry.ts` | `SourceRegistry` |
|
||||
| `src/sources/host.ts` | `SourceHost`, `SourceContext`, `Health`, `health` and the fixed health texts |
|
||||
| `src/sources/poll.ts` | What GitHub and Feishu polling share: `POLL_HORIZON_MS`, `polled`, `cursorAt`, and `pollThreads`, which keeps a failure on its Thread as `pollError` |
|
||||
| `src/sources/github.ts` | The GitHub kind and plugin entry, exported as `./github-watch`: a poll round of notifications, the pull requests of open Tickets with their CI, and subscribed Threads |
|
||||
| `src/sources/github-trigger.ts` | GitHub comments, reviews, Issue bodies and CI changes as Triggers, and GitHub addresses |
|
||||
| `src/sources/github-pull.ts` | The pull request rules: which pull requests a Ticket follows (`claimPull`, `observePull`), the closes it reports to the Engine, and `pullEndNotices`, the chat notice of a Ticket that ended merged or closed |
|
||||
| `src/sources/github-channel.ts` | The GitHub Channel: comments and review thread replies, `sent` or `gone` |
|
||||
| `src/sources/github-read.ts` | CI snapshots, repository reads, the `github/` resources, and `githubThreadContext`: an Issue's or pull request's conversation by page |
|
||||
| `src/sources/feishu.ts` | The Feishu kind and plugin entry, exported as `./feishu-watch` |
|
||||
| `src/sources/feishu-trigger.ts` | Feishu messages as Triggers, by one rule for live events, history pages and topic pages |
|
||||
| `src/sources/feishu-loops.ts` | The Feishu loops: the event consumer, history recovery, chat names and polling of subscribed topics |
|
||||
| `src/sources/feishu-channel.ts` | The Feishu Channel: topic replies, `sent` or `gone` |
|
||||
| `src/sources/feishu-read.ts` | The `feishu/` reads of a Trigger's message, its root, parent and topic, and `feishuThreadContext`, which gives them to assessments with their images |
|
||||
| `src/sources/admission-context.ts` | The bounded window an assessment starts from: the first pages of a Trigger's Thread, read through its kind, and the messages around the Trigger |
|
||||
| `src/sources/feishu-image.ts` | Reads a Feishu message's image as an attachment for the assessing Session |
|
||||
| `src/client/index.tsx` | The client plugin entry: the sidebar entry, the main panel and the page in DeepSeek Harness's Plugin Manager |
|
||||
| `src/client/app.tsx` | The panel: header, search, glossary, tabs and the page of the current route |
|
||||
| `src/client/provider.tsx` | What every page reads from its host: copy, calls, navigation, notices, the state poll, and `restart`, which reads the state quickly while dsh-superagent restarts |
|
||||
| `src/client/api.ts` | Calls and polls of the management API, typed by `src/app/api.ts`, and the Decision filters of the Eval status |
|
||||
| `src/client/route.ts` | The tabs, the settings groups, the typed routes that `parseRoute` reads, and the route memory with the settings group opened last |
|
||||
| `src/client/overview.tsx` | The overview |
|
||||
| `src/client/source.tsx`, `trigger.tsx`, `eval.tsx`, `execution.tsx` | One file per stage: its status, and its settings where it has some |
|
||||
| `src/client/thread.tsx`, `ticket.tsx` | The record pages: a Thread with its timeline, and a Ticket with its authorization, Inputs and Assignments |
|
||||
| `src/client/settings.tsx` | The Settings tab: the group navigation and the group on screen |
|
||||
| `src/client/settings-kit.tsx` | The building blocks of settings blocks: the settings the stage groups share, `useBlockDraft`, the fields, among them a Source kind's, and the history of saves |
|
||||
| `src/client/prompts.tsx` | The Prompts settings: the page owns the view it read and its editor the draft, and the Edit, Full prompt and History tabs render from them; a built-in part's editor shows a changed default and keeps or drops the override |
|
||||
| `src/client/prompt-config.ts` | The prompt configuration by section, by the team sections, skill names, built-in parts (`part/<key>`, with `withOverride`) and part headings the prompt views give, and the line diff of the full prompt, as pure functions |
|
||||
| `src/client/deployment.tsx` | The Deployment settings, also shown in DeepSeek Harness's Plugin Manager |
|
||||
| `src/client/draft.tsx` | Settings drafts: `draftReducer`, their states and their merge with a newer saved revision, `saveBarText`, the save bar's line, and the save bar |
|
||||
| `src/client/controls.tsx` | Local copies of DeepSeek Harness's controls, which follow its markup, keyboard behaviour and design tokens, since the bundle does not import `@deepseek-ai/dsh-client-ui-primitives`; they read no context |
|
||||
| `src/client/widgets.tsx` | The small components the pages share, which read the panel's context: sections, page heads, load states, times, links, confirmed actions, the setup check and Markdown |
|
||||
| `src/client/rows.tsx` | The rows the pages share: a Ticket, a Thread and a Decision, a Thread's chip, and the folding of runs of automated messages |
|
||||
| `src/client/markdown.ts` | The Markdown subset of reports and questions, and the bare addresses in messages, as blocks and inline parts |
|
||||
| `src/client/text.ts` | Copy by key and the wording of domain values, reasons, senders and times |
|
||||
| `src/client/marks.ts` | The marks of the states the views classify: a Ticket's or a Thread's stage, a Source's state and a Decision's outcome, each as a dot, an icon, a tone and a label |
|
||||
| `src/client/locales.ts` | All copy in both languages, and the glossary of [001 Domain model](001-domain.md) |
|
||||
| `src/client/client.css`, `css.d.ts` | The panel's stylesheet, loaded as text |
|
||||
|
||||
`src/sources/github.ts` and `src/sources/feishu.ts` are also the entries of the two Source plugins, built by `tsdown.config.ts` and exported in `package.json` as `./github-watch` and `./feishu-watch`. The bundle's `cordis.patch.yml` inserts the three plugins, with Feishu disabled.
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Covers |
|
||||
| --- | --- |
|
||||
| `tests/core-engine.spec.ts` | Domain rules on in-memory repositories: receiving, Eval, fan-out, resume and stop, interrupted Assignments, the pause, Policy changes, reports and outcomes, attaching, replies, failed deliveries and dismissed problems |
|
||||
| `tests/stage.spec.ts` | The Ticket stages, row by row |
|
||||
| `tests/policy.spec.ts` | Source scope, the gate, the admission table, Mandate revalidation, the Policy version, settings migrations and assessment outcomes |
|
||||
| `tests/registry.spec.ts` | `SourceRegistry`: instances, validation, Policy parts, Channels and Thread context |
|
||||
| `tests/sources.spec.ts` | The Feishu and GitHub Sources on a `SourceContext`: translation, polls and read positions, the pull request rules, health |
|
||||
| `tests/commands.spec.ts` | CLI calls as the deployment's accounts, the Channels' answers, and repository-scoped GitHub calls |
|
||||
| `tests/feishu-read.spec.ts`, `tests/github-read.spec.ts` | Feishu and GitHub reads |
|
||||
| `tests/admission-context.spec.ts`, `tests/feishu-image.spec.ts` | The Thread window of assessments, and Feishu image reads |
|
||||
| `tests/assessment.spec.ts` | What the MasterAssessor keeps per Eval, and when it lets go |
|
||||
| `tests/background.spec.ts` | Waiting for a worker's shell jobs and subagents: an end that woke it, and a host that stops |
|
||||
| `tests/bundle-patch.spec.ts` | The bundle's patch, read as DeepSeek Harness reads it, and the agent preset of Executor Sessions it defines |
|
||||
| `tests/effects.spec.ts` | The effect queue: coalescing, limits, retries with backoff, health and shutdown |
|
||||
| `tests/passes.spec.ts` | Passes: one at a time, the ones asked for during a pass run as one after it, and the runtime health a failed pass reports |
|
||||
| `tests/outbox.spec.ts` | The Outbox on a running Runtime: messages wait while processing is paused or their Source is not installed |
|
||||
| `tests/cache.spec.ts` | `TextCache` |
|
||||
| `tests/queue.spec.ts` | The Runtime's work through the effect queue, and what earlier versions stored |
|
||||
| `tests/followups.spec.ts` | A person's follow-up on a Ticket's Thread: its notice to the main Session, the tools that act on it, what a burst costs; a worker's hand-back and context follow, and the main Session's note |
|
||||
| `tests/health.spec.ts` | One health entry per part, each cleared by its own next success |
|
||||
| `tests/delivery.spec.ts` | `AgentDelivery` |
|
||||
| `tests/retry-health.spec.ts`, `tests/role-models.spec.ts` | Model budget retries, and the models of the two roles |
|
||||
| `tests/runtime.spec.ts` | The whole application on a simulated DeepSeek Harness host, through tools and HTTP |
|
||||
| `tests/tools.spec.ts` | The Sessions' tools: what `orchestrator_read` reads for each role, and the shapes and checks of the other main Session tools |
|
||||
| `tests/runtime-concurrency.spec.ts` | Work that overlaps: Triggers during a pass, Tickets that change during deliveries and launches |
|
||||
| `tests/http.spec.ts` | The same-origin and loopback checks, the status of each kind of failure, the body limit, path parameters and queries |
|
||||
| `tests/settings.spec.ts` | Settings saves that race a model or account check, a Policy sync that a save outran, and the refusals of the pause, a sync and a block |
|
||||
| `tests/views.spec.ts` | What the management views derive from the Store, such as the Thread a dropped message belongs to |
|
||||
| `tests/timeline.spec.ts` | A Thread's pages and a Trigger's neighbours by time and then id, with every received Trigger's time in one form |
|
||||
| `tests/migration.spec.ts` | The schema 11 migration: every stored record in its current shape, and the refusal of older schemas |
|
||||
| `tests/store.spec.ts` | Repositories and their working sets, in SQLite as in memory, the kv area, with every key of `KV` spelled only in `src/kv.ts` and the migration, settings history and queries |
|
||||
| `tests/prompts.spec.ts` | Prompt validation, assembly and versions |
|
||||
| `tests/cards.spec.ts` | The rules the built-in text must state, file by file |
|
||||
| `tests/model-surface.spec.ts` | Everything models read: tools, prompts, skills and inputs, checked against the tool schemas, the execution environment and the glossary, within a size budget per role |
|
||||
| `tests/deployment.spec.ts`, `tests/environment.spec.ts`, `tests/index.spec.ts` | The plugin configuration and setup, the agent's process environment, the plugin entry |
|
||||
| `tests/service-script.spec.ts` | `scripts/dsh-superagent` against fake `ps`, `tmux` and `dsh` |
|
||||
| `tests/client.spec.tsx`, `tests/navigation.spec.ts`, `tests/controls.spec.tsx` | The client plugin's registration and polling, Session navigation and route memory, the local controls, and the panel's imports, which have no cycle |
|
||||
| `tests/panel-overview.spec.tsx`, `panel-source.spec.tsx`, `panel-trigger.spec.tsx`, `panel-eval.spec.tsx`, `panel-execution.spec.tsx` | Each page of the panel, against a fake management API answered by route |
|
||||
| `tests/panel-settings.spec.tsx`, `panel-prompts.spec.tsx`, `panel-deployment.spec.tsx`, `panel-panel.spec.tsx` | The settings groups, drafts and history, the prompts, the Deployment page, and the panel's search, glossary and route memory |
|
||||
| `tests/draft.spec.ts`, `tests/prompt-config.spec.ts`, `tests/markdown.spec.ts`, `tests/marks.spec.ts` | Draft states, merges and the save bar's line; the prompt configuration by section and the line diff; the Markdown subset and its links; the mark of every state the views classify |
|
||||
| `tests/locales.spec.ts`, `tests/styles.spec.ts` | The copy in both languages, the stylesheet |
|
||||
| `tests/class-docs.spec.ts` | Every class in `src` has a JSDoc block before it |
|
||||
| `tests/docs.spec.ts` | Numbered documents link only to earlier ones, every local link resolves, no document has merge conflict markers, and this document's tables list every module and every test |
|
||||
|
||||
`tests/fixtures/` holds what several tests share: `host.ts`, a simulated DeepSeek Harness host; `panel-server.ts` and `panel.tsx`, the fake management API and the panel around a page; `deployment.ts`, a resolved deployment; `git.ts`, the environment of every git the tests start, which names no repository; and `async.ts`.
|
||||
|
||||
## Persistence
|
||||
|
||||
One SQLite file, `<stateDir>/orchestrator.sqlite`, which only the Store opens.
|
||||
|
||||
| Table | Holds | Written by |
|
||||
| --- | --- | --- |
|
||||
| `trigger` | Each Trigger, its time `at` in the one form `Date#toISOString` gives, set when the Engine receives it, so times compare as text | Engine |
|
||||
| `disposition` | Where each Trigger went: `filtered` with the Source's reason code, its Eval and Decision, the Tickets it reached, by fan-out or attachment, whether it was requeued, the Tickets that handed it back with their reasons, and those whose Executor acknowledged or answered it | Engine |
|
||||
| `trigger_thread` | Which Threads each Trigger is on, with its time and whether a person wrote it, so a Thread's Triggers are read without scanning every Trigger, in order of time and then id: a page of a Thread's timeline continues from a `<at>|<id>` cursor, and Triggers that share a time are not skipped | Store, with each Trigger |
|
||||
| `thread` | Threads: Subscriptions, the Tickets that follow it for context, queued Triggers, the Eval in progress, the count of its expiries and `meta`; the generated columns `active`, set while Triggers are queued or an Eval runs, and `subscribed`, its Source while a Ticket subscribes to it | Engine; Sources write `meta` |
|
||||
| `ticket` | Tickets with their Mandate, origin, execution, Assignments, Channels, Inputs and the Threads they follow; the generated columns `open`, set while it has not ended, and `active`, set while Inputs are undelivered or the latest Assignment is pending or running | Engine |
|
||||
| `decision` | Decisions, by Thread | Engine |
|
||||
| `effect` | The effect queue: per kind and key, the payload, its order, failed attempts with the last error, and when it is due | Effects; the schema 11 migration queues what earlier versions kept for the main Session |
|
||||
| `outbox` | The receipt of each message for a Channel, by id, with the Ticket it is for and its `status`, `pending`, `sent` or `dropped`, kept so an id is never queued twice; for a dropped message, when and why, as the generated column `dropped`, its time, and a reason code. A message that waits has a `reply` effect, which holds its attempts | The Outbox |
|
||||
| `kv` | Every other record, by key | The part that names it |
|
||||
|
||||
A generated column is computed from its row's JSON and is empty outside its set, and each has an index over its set, so a pass reads only active work. A database that predates a column gets it when the Store opens it.
|
||||
|
||||
`KV` (`src/kv.ts`) defines each key-value record's owner, key and type. All readers and writers use these entries; only migration code names older keys.
|
||||
|
||||
| Records | Keys or contents |
|
||||
| --- | --- |
|
||||
| Schema | Current schema version |
|
||||
| Settings versions | `settings-revision`, `settings-version:<n>`, `settings-block-revision:<block>`; snapshots include origin, Policy version and affected Tickets |
|
||||
| Prompt versions | `prompt-revision`, `prompt-version:<n>` |
|
||||
| Main Session | `master-id` |
|
||||
| Session prompt history | `session-prompts:<session id>` |
|
||||
| Source state | Read positions such as `github-cursor:<number>`, PR CI fingerprints and seen Feishu-topic messages; keys begin with the kind or `thread-` plus a Thread key |
|
||||
| Display names | `source-labels:<source id>` |
|
||||
|
||||
Dismissal is stored on its Decision. Input text is rendered from the Ticket when read, so neither needs a separate key-value record.
|
||||
|
||||
Read caches use bounded in-memory `TextCache` instances and are lost on restart. A Session paging through an original Source record or prompt preview must reread offset 0 after a restart. Feishu links resolved after intake are cached the same way; link-based search finds them only after that link has been read during the current process lifetime.
|
||||
|
||||
Nothing deletes old records yet, so these grow without bound: the `trigger`, `disposition` and `decision` rows; ended Tickets and their Threads; the outbox's receipts; and in `kv`, every saved settings revision and prompt version, the prompt revisions each Session worked under, and the Sources' records per Thread and per Issue or pull request.
|
||||
|
||||
`SCHEMA_VERSION` only increases. A database of a newer version refuses to start; `src/app/migration.ts` upgrades a database of the pre-release schema 10 once, at start, and refuses an older one.
|
||||
|
||||
## Model-facing interfaces
|
||||
|
||||
### Main Session tools
|
||||
|
||||
The main Session has 14 application tools:
|
||||
|
||||
| Purpose | Tools |
|
||||
| --- | --- |
|
||||
| Read and route | `orchestrator_read`, `orchestrator_route` |
|
||||
| Manage Tickets | `orchestrator_ticket_retry`, `orchestrator_ticket_request_status`, `orchestrator_ticket_note` |
|
||||
| Re-evaluate | `orchestrator_trigger_requeue` |
|
||||
| Assess proactive work | `orchestrator_eval`, `orchestrator_eval_read`, `orchestrator_eval_decide` |
|
||||
| Communicate and unsubscribe | `orchestrator_reply`, `orchestrator_thread_unsubscribe` |
|
||||
| Configure | `orchestrator_settings_save`, `orchestrator_policy_sync`, `orchestrator_prompts_save` |
|
||||
|
||||
`orchestrator_route` accepts 1–20 decisions in order. Each accept, attach, stop or ignore runs as a separate Engine command. It also permits ignoring an inferred candidate directly from its card. `orchestrator_ticket_note` sends a fact as context, not authority.
|
||||
|
||||
Settings saves select `policy`, `eval`, `executor`, `pause`, or `source`; the last also names its Source id. A prompt save with `preview: true` composes text without saving.
|
||||
|
||||
The main Session also has DeepSeek Harness's `skill`, `bash`, `read`, `read_image`, `job_output`, `job_list` and `job_kill`. Avoiding code reads is a prompt rule, not a tool restriction.
|
||||
|
||||
### Presets and wait limits
|
||||
|
||||
New main Sessions use `dsh-superagent-router` (`ROUTER_PRESET`). Shell commands and job waits return after 5 seconds. If the Web UI selects another preset before the first turn, `Sessions` restores this preset, or `WORKER_PRESET` for a worker.
|
||||
|
||||
The `noWait` guard in `src/app/sessions.ts` refuses main-Session shell commands that invoke `sleep` at a command position, shell `timeoutMs` above 30 seconds, and `job_output` waits above 5 seconds. Reports and job results arrive automatically, so waiting only delays incoming candidates.
|
||||
|
||||
Existing main Sessions on `standard` also need explicit timeouts for foreground commands and job waits; otherwise that preset's defaults hold the step for 60 and 30 seconds respectively. The host determines the current preset through `presetForSession`, falls back to the Session projection or header, and assumes `standard` if it cannot read the value.
|
||||
|
||||
### Executor tools
|
||||
|
||||
Each Executor has six application tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `orchestrator_read` | Read resources available to its own Ticket |
|
||||
| `orchestrator_ack` | Acknowledge a request |
|
||||
| `orchestrator_report` | Report progress or an outcome, binding a pull request under the pull-request rules |
|
||||
| `orchestrator_thread_unrelated` | Drop an unrelated attached Thread, hand back its owned follow-ups and tell the main Session why |
|
||||
| `orchestrator_thread_follow` | Subscribe to a related Thread for context and tell the main Session |
|
||||
| `orchestrator_trigger_handback` | Hand back a message once, with a reason carried by its candidate |
|
||||
|
||||
A hand-back returns `queued: false` if another Ticket still owns the message, or this Ticket never owned it. For example, a bot message went to Eval independently.
|
||||
|
||||
The worker preset is defined in `cordis.patch.yml` and selected through `WORKER_PRESET`. `WORK_TOOLS` in `src/app/sessions.ts` retains available file, search, shell, job, web, skill, todo and subagent tools. Workers do not receive tools for asking or presenting to a person, changing the profile, scheduling work outside worker slots, starting or messaging other Sessions, or editing external documents. Tool calls are refused after the Ticket ends.
|
||||
|
||||
### Result shapes and assessment context
|
||||
|
||||
Routing results use `{decision?, reason, ticket?}`. `orchestrator_route` returns `{results}`, preserving item order and adding each item's `trigger_id` and `kind`. Invalid or refused items return `refused` and a reason without stopping the batch.
|
||||
|
||||
Settings and prompt saves return the saved configuration. Policy sync returns the Source, allowed-list size, and `added` and `removed` names.
|
||||
|
||||
`orchestrator_eval` returns context rather than instructions. Assessment instructions live in `runtime/admission` (`prompts/admission.md`), and rules in `runtime/policy`. Its context includes:
|
||||
|
||||
- The Trigger text, up to 4,000 characters, with image ids and other Triggers in the batch.
|
||||
- Up to eight `related_tickets`, open ones first, and `tickets_total`.
|
||||
- The Eval's `policy_version`.
|
||||
- A Thread window: the first five messages and five on either side of the Trigger. Messages use `{id, from, at, text}`, normally limited to 400 characters, or 1,200 for the two on either side. Truncated messages include `chars`; image references use `images`.
|
||||
- Other loaded messages as `{id, from, preview}` stubs of 50 characters. `orchestrator_eval_read` expands them by id.
|
||||
|
||||
Ticket cards use the closed `TICKET_STATES` set from `src/app/cards.ts`: an open stage such as `person/review` or `working/queued`, or an ended outcome. Acknowledgements are limited to 100 words whether submitted through accept, assessment or `orchestrator_ack`.
|
||||
|
||||
### Read resources
|
||||
|
||||
`RESOURCES` in `src/app/resources.ts` defines the resources and generates the tool's `resource` description.
|
||||
|
||||
| Resources | Content |
|
||||
| --- | --- |
|
||||
| `tickets`, `triggers`, `threads`, `decisions` | Paged lists as `{items, next_offset}`; activity lists put newest first |
|
||||
| `decision/<id>` | One Decision |
|
||||
| `thread/<key>` | Label, Tickets, recent Decisions and a page of Triggers |
|
||||
| `trigger/<id>` | Message text, replied-to Trigger and neighboring messages, excluding CI runs |
|
||||
| `ticket/<id>` | Related ids, source-batch Triggers, Threads, and first pages of Assignments and Inputs |
|
||||
| Ticket subresources | `/assignments`, `/inputs`, `/assignment`, `/result`, `/report/<assignment id>`, `/transcript`, `/message/<n>`, `/input/<id>` |
|
||||
| `settings`, `settings/<block>` | Revisions and summaries, or a complete block ready to save |
|
||||
| `prompts`, `prompts/<revision>`, `prompts/current`, `prompts/defaults`, `prompt-preview/<id>` | Prompt configurations and composed text |
|
||||
| `models`, `models/<provider>` | Available models |
|
||||
| `feishu/<trigger id>/…`, `github/…` | Original Source records, described in the Source skills |
|
||||
|
||||
Ticket Threads are marked `context` for context-only follows and `bound` for Source bindings. Assignment pages are newest first and include report previews. Continue lists with `next_offset`. `/input/<id>` accepts an Input's name or id. `settings` includes small blocks in full and summarizes Policy and Source blocks.
|
||||
|
||||
Source resources include message neighborhoods, `github/pulls/<n>/thread/<comment id>` and `github/commits/<sha>/checks`.
|
||||
|
||||
An original's messages read in one compact shape, `{id, from, at, text, reply_to?, path?, line?, url?}`, not as GitHub or Feishu records them.
|
||||
|
||||
An Executor Session reads only its own Ticket, its Threads, the Triggers that reached it or that its request held, the prompt versions it worked under and the originals, and its pages leave out the ids of what it cannot read, Decisions and other Tickets.
|
||||
|
||||
Every id a page shows reads through one of these, so a Session walks from one object to the next; hashes nothing reads by, such as an Eval's id or a Policy version, stay off the pages.
|
||||
|
||||
A text page has 4,000 UTF-16 code units by default and 8,000 at most, a list 20 items by default and 50 at most.
|
||||
|
||||
The names `event/`, `task/` and `observation/` of earlier versions still resolve.
|
||||
|
||||
### Messages delivered to Sessions
|
||||
|
||||
These messages are steered into the main Session's turn:
|
||||
|
||||
| Message | Content |
|
||||
| --- | --- |
|
||||
| Candidate | Batch Trigger cards, Thread, `related_tickets`, `tickets_total`, `delivered_to`, referenced Tickets from Issue/PR links, `handed_back` reasons and the next action |
|
||||
| `follow_up` | One message as its Executor sees it, `replies_to`, receiving Tickets, and the reminder that Executors decide it while the main Session may only stop work |
|
||||
| `worker_report` | Ticket card, originating Trigger, state and summary up to 600 characters; `chars` and a read pointer when truncated |
|
||||
| `worker_attention` | Ticket card carrying the question |
|
||||
|
||||
Other notices are injected without waking the main Session: `ticket_ended`, `settings_saved`, prompt saves, Trigger requeues, dropped Subscriptions and context follows. A settings notice names the block, changed paths and `read: settings/<block>`. The main Session receives no redundant notice for a save, requeue, unsubscribe or attach it performed itself.
|
||||
|
||||
Workers receive assignments and Inputs inline. The first message includes the title, up to 1,500 characters of assignment text and the source Trigger card. Inputs carry a Trigger card with up to 600 characters and `replies_to`, or plain Input text. Read pointers appear only for truncated content.
|
||||
|
||||
### Panel-only actions
|
||||
|
||||
There is no tool for dismissing an unavailable Decision, rerunning setup, refreshing Source display names, or sending an Executor-owned human follow-up to Eval. The last action is deliberately refused by `orchestrator_trigger_requeue`. Other panel actions have equivalent tools.
|
||||
|
||||
Most built-in instructions live in `prompts/*.md`, with one sentence per line joined by `promptFile`. Source skills live in `skills/dsh-superagent-*/SKILL.md`. Tool descriptions and fixed notices such as restart recovery notes remain in code. Assessment instructions live only in `prompts/admission.md`.
|
||||
|
||||
`tests/model-surface.spec.ts` checks these surfaces for drift. [004 Prompts](004-prompts.md) explains the team's editable parts.
|
||||
|
||||
## Configuration reload
|
||||
|
||||
Every plugin configuration key except `stateDir` is volatile. The panel's Deployment settings and DeepSeek Harness's Plugin Manager edit them through DeepSeek Harness's configuration form; the host writes `cordis.patch.yml` and updates the values in place, and dsh-superagent reads each limit when it uses it, so a change applies without a restart.
|
||||
|
||||
A settings save that changes a Source field setup reads, such as an account's CLI, and any save while setup waits, restarts dsh-superagent. DeepSeek Harness's settings service does not serve the entry during that restart, so after its own restart dsh-superagent asks it to read the entry again, and an open form becomes editable again.
|
||||
|
||||
## Reviewing a change against real data
|
||||
|
||||
Point `DSH_HOME` at a temporary directory and create a new profile there. Copy the database from a backup, disable every Source, pause processing and remove the main Session id. Give the GitHub and Feishu CLIs read-only stand-ins.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Trust: which Trigger fields are trusted, such as the Source's actor and Thread membership, and which are not, such as the body and quotes.
|
||||
- Deployment isolation: the agent's profile on its own pinned DeepSeek Harness installation, so upgrading DeepSeek Harness elsewhere does not break it.
|
||||
@@ -0,0 +1,425 @@
|
||||
# 008 Cookbook
|
||||
|
||||
This cookbook shows how to extend dsh-superagent. It uses the terms in [001 Domain model](001-domain.md) and [002 Authorization](002-authorization.md), and the components in [007 Architecture](007-architecture.md).
|
||||
|
||||
## Add a Source
|
||||
|
||||
This recipe implements a Source kind called `webhook`. It reads an imaginary message queue through an invented CLI, `hookq`, and converts messages to Triggers. All other interfaces are part of dsh-superagent.
|
||||
|
||||
The implementation has four responsibilities: translate events, define receiving and authorization rules, provide replies and context, and maintain a polling loop. The numbered steps build those pieces in order.
|
||||
|
||||
The example assumes this CLI contract:
|
||||
|
||||
- `hookq pull --after <n> --json` prints the messages after position `n`, oldest first: `{"me": "<the agent's id on the queue>", "items": […], "next": <position>}`. An item has `id`, `conversation`, `thread` (the id of the message that started its thread, absent on that message), `sender` with `id` and `bot`, `mentions_me`, `text`, `at` and `url`.
|
||||
- `hookq reply --conversation <id> --to <message id> --key <key> --text <text>` replies under a message, once per key. For a conversation that was deleted it fails with `{"error": {"code": 410, "message": "…"}}` on standard error.
|
||||
- `hookq thread --conversation <id> --root <message id> --json`, with `--after <cursor>` for a later page, prints a page of a thread, oldest first: `{"items": […], "next": "<cursor>"}`, without `next` on the last page.
|
||||
|
||||
Put the kind and its plugin entry in `src/sources/webhook.ts`, following `src/sources/github.ts`. If the Source grows, split translation, Channel and polling code into separate modules as the Feishu Source does.
|
||||
|
||||
### 1. Map the events onto the domain
|
||||
|
||||
Choose stable mappings before writing code. Trigger ids, Thread ids and Channel addresses are persisted, so changing them later can disconnect existing records.
|
||||
|
||||
| Concept | For the webhook queue |
|
||||
| --- | --- |
|
||||
| Trigger | One message |
|
||||
| Thread | A message and the replies to it in one conversation: `<conversation>:<id of the first message>` |
|
||||
| Actor | The sender's id on the queue, as the allowed list names it |
|
||||
| Channel address | The conversation and the message a reply goes under |
|
||||
| Receiving scope | A conversation, with its own row in the admission table |
|
||||
|
||||
### 2. Configuration: schema, fields, facts and health
|
||||
|
||||
The module imports what the steps below use:
|
||||
|
||||
```ts
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { z } from 'zod'
|
||||
import { key, threadKey, type OutboundMessage, type Trigger } from '../core/index.ts'
|
||||
import { CliError } from '../infra/commands.ts'
|
||||
import { KV } from '../kv.ts'
|
||||
import { health, sourceContext, type SourceContext } from './host.ts'
|
||||
import type { SourceKind } from './kind.ts'
|
||||
```
|
||||
|
||||
```ts
|
||||
const ConfigSchema = z.object({
|
||||
conversations: z.array(z.object({
|
||||
id: z.string().regex(/^[a-z0-9-]+$/),
|
||||
listen: z.enum(['mentions', 'all']),
|
||||
}).strict()).default([]),
|
||||
pollSeconds: z.number().int().min(10).max(3600).default(30),
|
||||
}).strict()
|
||||
export type WebhookConfig = z.infer<typeof ConfigSchema>
|
||||
|
||||
export const webhookKind: SourceKind<WebhookConfig> = {
|
||||
kind: 'webhook',
|
||||
title: { zh: 'Webhook 队列', en: 'Webhook queue' },
|
||||
description: { zh: '接收 webhook 队列里下列对话的消息。', en: 'Receives the messages of these conversations from the webhook queue.' },
|
||||
schema: ConfigSchema,
|
||||
fields: [
|
||||
{ key: 'conversations', type: 'rows', label: { zh: '接收的对话', en: 'Conversations' }, columns: [
|
||||
{ key: 'id', type: 'text', label: { zh: '对话', en: 'Conversation' }, pattern: '[a-z0-9-]+' },
|
||||
{ key: 'listen', type: 'select', label: { zh: '接收', en: 'Receive' }, options: [
|
||||
{ value: 'mentions', label: { zh: '仅 @', en: '@ only' } }, { value: 'all', label: { zh: '全部消息', en: 'All' } }] },
|
||||
] },
|
||||
{ key: 'pollSeconds', type: 'number', advanced: true, min: 10, max: 3600,
|
||||
label: { zh: '轮询间隔 / 秒', en: 'Polling interval / seconds' } },
|
||||
],
|
||||
health: ['webhook'],
|
||||
// find, scope, channel, threadContext, policy and environment follow in the next steps.
|
||||
}
|
||||
```
|
||||
|
||||
- `kind` is the stable Source id. It appears in Trigger `source`, Thread-key prefixes, the `source:webhook` settings block and the Source Policy.
|
||||
- `schema` validates stored configuration and supplies defaults. Before an operator saves an instance, the registry creates an enabled instance from those defaults. Make defaults receive nothing; this example defaults to no conversations.
|
||||
- `fields` generates the Source settings form without custom panel code. A `rows` field edits entries keyed by its first column; `advanced` hides less common settings. Implement `refreshLabels` if the Source can resolve ids to display names. Save those names under `KV.sources.labels(kind)`. Columns with `choose` and `refresh` can then offer them.
|
||||
- Optional `facts(deployment)` displays values derived by setup, such as the account behind a CLI. Setup currently checks only GitHub and Feishu fields listed in `SETUP_FIELDS`, so this example supplies no facts.
|
||||
- `health` declares the Source's health entries, initially `starting`. Registration claims each name and refuses collisions with entries such as `runtime`, `reply`, `worker` or effect kinds. Use the kind's name and reserve `sync:<id>` for Policy syncs. An `error` makes the status script exit 2 and the hourly check notify the operator, so report it only when human action may be needed.
|
||||
|
||||
### 3. Translate a message into a Trigger
|
||||
|
||||
Validate CLI responses before converting them:
|
||||
|
||||
```ts
|
||||
/** One message as `hookq pull` and `hookq thread` print it. */
|
||||
const MessageSchema = z.looseObject({
|
||||
id: z.string().min(1), conversation: z.string().min(1), thread: z.string().optional(),
|
||||
sender: z.looseObject({ id: z.string(), bot: z.boolean().default(false) }), mentions_me: z.boolean().default(false),
|
||||
text: z.string(), at: z.string(), url: z.string().optional(),
|
||||
})
|
||||
type QueueMessage = z.infer<typeof MessageSchema>
|
||||
/** The answer of `hookq pull`, whose items are read one by one, and of `hookq thread`. */
|
||||
const PageSchema = z.looseObject({ me: z.string(), items: z.array(z.unknown()), next: z.number().int() })
|
||||
const ThreadPageSchema = z.looseObject({ items: z.array(MessageSchema), next: z.string().optional() })
|
||||
```
|
||||
|
||||
```ts
|
||||
/** The Trigger of one queue message; the agent's own messages produce none. */
|
||||
export function webhookTrigger(message: QueueMessage, me: string): Trigger | undefined {
|
||||
if (message.sender.id === me) return undefined
|
||||
const thread = `${message.conversation}:${message.thread ?? message.id}`
|
||||
return {
|
||||
id: key('webhook', message.id),
|
||||
source: 'webhook',
|
||||
threads: [{ source: 'webhook', id: thread }],
|
||||
actor: { source: 'webhook', id: message.sender.id },
|
||||
human: !message.sender.bot,
|
||||
...(message.mentions_me ? { addressed: 'mention' as const } : {}),
|
||||
at: message.at,
|
||||
text: message.text,
|
||||
channel: { source: 'webhook', thread: `webhook:${thread}`,
|
||||
address: { conversation: message.conversation, message: message.id, ...(message.url ? { url: message.url } : {}) } },
|
||||
...(message.url ? { url: message.url } : {}),
|
||||
meta: { conversation: message.conversation, messageId: message.id, root: message.thread ?? message.id },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Stable ids.** Derive the Trigger id from the queue id through `key`. `Engine.receive` returns `duplicate` for stored ids, and duplicate accepts create only one Ticket. Re-reading messages is therefore safe. To treat edits as new events, include an edit version in the id, as GitHub does with comment `updated_at`.
|
||||
- **Threads.** The first Thread is primary and owns the Eval. Additional Threads add Correlation context. The Source chooses the id; its full key is `webhook:<id>`.
|
||||
- **Sender.** Use Source-native ids in `actor`. Set `human: false` for bots and CI. Listed bots can only become inferred candidates. An empty actor id, as for CI, uses the admission cell for unlisted, unaddressed senders. Set `addressed` to `mention` or `direct` only when the message actually addresses the agent; this gives an allowed person's message explicit strength. Never produce a Trigger for the agent's own message.
|
||||
- **Reply destination and metadata.** `channel` identifies where replies go; `url` links to the source message. `channel.thread` groups messages for ordered delivery: a Ticket's later messages wait behind its earlier ones until an earlier message has failed three times. Other Tickets do not wait. Keep Source-specific fields in `meta`, such as the receiving conversation and Thread root. Both `meta` and `text` are untrusted data, not authorization.
|
||||
|
||||
Agents can read the new kind through `orchestrator_read trigger/<id>` and `thread/<key>`. These expose message text, disposition and neighboring Triggers. A new kind does not automatically receive a built-in skill, resources for original service records or pull-request binding.
|
||||
|
||||
Text search works without extra code. Implement `find(query)` when people use Source-specific references such as message ids, URLs or `#12`. Return the Thread keys and search terms those references identify:
|
||||
|
||||
```ts
|
||||
find: query => ({ threads: [], terms: [...new Set(query.match(/\bm[0-9]+\b/g) ?? [])] }),
|
||||
```
|
||||
|
||||
Here a queue message id in the query, such as `m42`, searches the Triggers for it.
|
||||
|
||||
### 4. What the Source receives: scope
|
||||
|
||||
```ts
|
||||
scope({ meta }, config) {
|
||||
const conversation = config.conversations.find(item => item.id === meta.conversation)
|
||||
if (!conversation) return { listened: false, reason: 'conversation_not_listed' }
|
||||
return { listened: true, observed: conversation.listen === 'all', row: conversation.id }
|
||||
},
|
||||
```
|
||||
|
||||
The Engine calls `scope` through `SourceRegistry` for each incoming Trigger. Out-of-scope messages are stored as filtered with their reason and create no Thread. Pass messages to `receive` rather than silently dropping them in the polling loop, so the panel can explain filtering.
|
||||
|
||||
`observed: true` means the Source receives unaddressed messages in that scope, allowing admission-table decisions for them. `row` identifies the corresponding admission row.
|
||||
|
||||
Revalidation calls `scope` with the Mandate's stored metadata. Make it depend only on `meta` and configuration. Resuming requires `listened`, not `observed`. Without `scope`, a kind receives every Trigger it produces but observes none.
|
||||
|
||||
### 5. Replies: the Channel
|
||||
|
||||
```ts
|
||||
channel: {
|
||||
allowed: (address, config) => config.conversations.some(item => item.id === address.conversation),
|
||||
send: sendWebhook,
|
||||
link: address => ({ url: address.url ?? '',
|
||||
label: { zh: `Webhook 对话 ${address.conversation}`, en: `Webhook conversation ${address.conversation}` } }),
|
||||
},
|
||||
```
|
||||
|
||||
```ts
|
||||
export async function sendWebhook(host: Pick<SourceContext, 'commands'>, message: OutboundMessage): Promise<'sent' | 'gone'> {
|
||||
const { conversation, message: to } = message.channel.address
|
||||
if (!conversation || !to) throw new Error('Missing webhook reply target')
|
||||
try {
|
||||
await host.commands.run('hookq', ['reply', '--conversation', conversation, '--to', to, '--key', message.id,
|
||||
'--text', message.text])
|
||||
return 'sent'
|
||||
} catch (error) {
|
||||
if (error instanceof CliError && error.code === 410) return 'gone'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Idempotent sends.** Outbox calls `SourceRegistry.send`, which invokes `channel.send`. Messages wait while the Source plugin is unloaded. A send may fail after the remote service received it, so retries must not duplicate messages. Use `message.id` as an idempotency key: `hookq` supports `--key`; GitHub stores a hidden comment marker; Feishu accepts an idempotency key.
|
||||
- **Permanent versus retryable failure.** Return `sent` on success and `gone` only when delivery is permanently impossible, such as the example's 410. A gone message is retained as dropped and never queued again; it appears in `GET sources` and on the Source page. Throw for other failures so Outbox retries after 5 seconds, doubling to 300 seconds. `CliError.code` is the code reported by the CLI output.
|
||||
- **Allowed destinations.** `allowed(address, config)` checks only the saved address and current configuration. Put every required fact in the address; for example, Feishu records whether the chat is direct. A destination no longer listened to is dropped, while a disabled Source leaves messages waiting. Without `allowed`, all addresses are permitted while the Source is enabled.
|
||||
- **Links.** `link(address, links)` returns the URL and label the panel displays through `SourceRegistry.link`.
|
||||
|
||||
### 6. Thread context for assessments
|
||||
|
||||
```ts
|
||||
threadContext: {
|
||||
messageId: trigger => String(trigger.meta.messageId ?? ''),
|
||||
// A queue thread is flat: its root, and the message a reply answers, are on its first page, as on GitHub.
|
||||
read: async (commands, trigger, _part, cursor) => {
|
||||
const page = ThreadPageSchema.parse(JSON.parse(await commands.run('hookq', ['thread', '--conversation',
|
||||
String(trigger.meta.conversation), '--root', String(trigger.meta.root), '--json', ...(cursor ? ['--after', cursor] : [])])))
|
||||
const messages = page.items.map(item => ({ id: item.id, from: item.sender.id, at: item.at, text: item.text,
|
||||
...(item.sender.bot ? { bot: true as const } : {}), ...(item.url ? { url: item.url } : {}) }))
|
||||
return { messages, ...(page.next ? { next: page.next } : {}), body: { messages } }
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Proactive assessment needs the conversation around a Trigger. `orchestrator_eval` reads an initial window; `orchestrator_eval_read` expands pages, the root or the parent message. Implement the optional `threadContext` interface to provide these reads:
|
||||
|
||||
- `read(commands, trigger, part, cursor, config)` returns a `ContextPart`. Its `messages` use `ContextMessage`: `{id, from, at, text}` plus applicable `bot`, `deleted`, `reply_to`, `url`, `path` and `line` fields. `next` is omitted on the last page. `body` is the content shown to the assessing Session, here the same messages. Optional `status` can describe special results such as Feishu's `chat_neighbours`. `part` is `thread`, `root` or `parent`.
|
||||
- `messageId(trigger)` finds the Trigger's own message among them.
|
||||
- `images(text)` and `image(…)` are for a kind whose messages carry images, as Feishu's do; the example leaves them out.
|
||||
|
||||
A kind without `threadContext` gives assessments no Thread context; the Trigger's own text stays readable.
|
||||
|
||||
If messages reply to earlier messages, add `repliesTo(trigger)`. It returns the corresponding Trigger id, allowing follow-up notices and worker Inputs to show the replied-to message when stored. For example, if `hookq` supplies `reply_to` and translation stores it as `meta.replyTo`:
|
||||
|
||||
```ts
|
||||
repliesTo: trigger => typeof trigger.meta.replyTo === 'string' ? key('webhook', trigger.meta.replyTo) : undefined,
|
||||
```
|
||||
|
||||
### 7. Who may ask: the Source's part of the Policy
|
||||
|
||||
```ts
|
||||
policy: {
|
||||
schema: z.object({ allowed: z.array(z.object({ id: z.string().min(1), name: z.string().trim().max(100).optional() }).strict())
|
||||
.default([]) }).strict(),
|
||||
fields: [{ key: 'allowed', type: 'rows', label: { zh: '能发起工作的发送者', en: 'Senders who may originate work' }, columns: [
|
||||
{ key: 'id', type: 'text', label: { zh: '发送者', en: 'Sender' } },
|
||||
{ key: 'name', type: 'text', label: { zh: '名字', en: 'Name' }, optional: true }] }],
|
||||
explicit: { zh: '名单里的人在消息里 @ 智能体。', en: 'Someone on the list mentions the agent in a message.' },
|
||||
rows: stored => ConfigSchema.parse(stored).conversations.map(conversation => {
|
||||
const all = conversation.listen === 'all'
|
||||
return { id: conversation.id, label: conversation.id, note: all ? { zh: '全部消息', en: 'All' } : { zh: '仅 @', en: '@ only' },
|
||||
cells: { listedUnaddressed: all, unlistedAddressed: true, unlistedUnaddressed: all } }
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
- `schema` validates the Source Policy, including `allowed` sender ids. Id comparison is case-insensitive. Admission tables are validated separately and shared across kinds. This Policy part is saved at `policy.sources.webhook` and contributes to the Policy version.
|
||||
- `fields` generates the form under **Eval → On each Source**, beginning with `allowed`. `explicit` explains how to address the agent. Optional `unnamed: {person, bot}` supplies display labels for opaque ids such as Feishu's `ou_…`; this example omits it because queue ids are readable names.
|
||||
- `rows(config)` lists admission scopes and their available cells. Each row id must match the `row` returned by `scope`. Unaddressed-message cells are available only where the Source observes those messages. Kinds without `rows` use default cells.
|
||||
- Optional `bots` lists bots eligible for inferred candidacy, using the same entry shape as `allowed`. Without it, no bot is admitted.
|
||||
- Optional `sync: {label, ready(part), run(host, part, signal)}` refreshes membership at startup and on **Sync now**. Feishu uses chat membership. Failures appear under `sync:<id>` and retry after one minute, doubling to one hour until successful.
|
||||
|
||||
### 8. Variables for the Executor
|
||||
|
||||
```ts
|
||||
environment: {
|
||||
variables: [{ key: 'DSH_SUPERAGENT_WEBHOOK_CONVERSATION', description: {
|
||||
zh: '接下工单的 webhook 对话;工单不是从这个来源来的时为空',
|
||||
en: 'The webhook conversation that started the ticket; empty when the ticket did not start on this Source' } }],
|
||||
values: ({ trigger }) => ({
|
||||
DSH_SUPERAGENT_WEBHOOK_CONVERSATION: trigger?.source === 'webhook' ? String(trigger.meta.conversation ?? '') : '' }),
|
||||
},
|
||||
```
|
||||
|
||||
Execution settings show declared `variables` beside the prepare command even when the Source is disabled. While enabled, `values` supplies Ticket-specific values using deployment, configuration and the originating Trigger.
|
||||
|
||||
The prepare command reads environment variables; the worker reads their values in its instructions. Use the `DSH_SUPERAGENT_<KIND>_…` prefix, return an empty value for Tickets from another Source, and never include credentials.
|
||||
|
||||
### 9. The plugin entry and its loop
|
||||
|
||||
```ts
|
||||
export const name = 'dsh-superagent-webhook'
|
||||
export const inject = ['orchestrator']
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.orchestrator.sources.register(webhookKind), 'webhook: source kind')
|
||||
ctx.effect(() => {
|
||||
const source = sourceContext(ctx.orchestrator, webhookKind)
|
||||
const controller = new AbortController()
|
||||
// DSH ends its process on an unhandled rejection, so a failure that escapes the loop becomes the Source's health.
|
||||
const running = watch(source, controller.signal)
|
||||
.catch((error: unknown) => { source.report('webhook', { state: 'error', detail: String(error) }) })
|
||||
return async () => { controller.abort(); await running }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The plugin injects `orchestrator`. Its first effect registers the kind and unregisters it on disposal. The second runs the polling loop with a `SourceContext`. Disposal aborts the loop and awaits completion, including during application stop or restart.
|
||||
|
||||
```ts
|
||||
async function watch(ctx: SourceContext, signal: AbortSignal): Promise<void> {
|
||||
await ctx.ready
|
||||
// Setup restarts dsh-superagent once it resolves the accounts; until then the Source reads nothing.
|
||||
if (!ctx.configured()) { ctx.report('webhook', health('paused', 'setupPending')); return }
|
||||
let failures = 0
|
||||
while (!signal.aborted) {
|
||||
const config = ctx.config<WebhookConfig>('webhook')
|
||||
if (!config) ctx.report('webhook', health('paused', 'sourceOff'))
|
||||
else {
|
||||
const at = new Date().toISOString()
|
||||
try {
|
||||
const { read, unreadable } = await pollQueue(ctx, signal)
|
||||
failures = 0
|
||||
ctx.report('webhook', { state: 'ready', detail: `${read} messages, ${unreadable} unreadable`, at })
|
||||
} catch (error) {
|
||||
failures += 1
|
||||
// One network failure is retried on the next round; three in a row need a person.
|
||||
if (!signal.aborted && failures >= 3) {
|
||||
ctx.report('webhook', health('error', 'pollFailures', { count: String(failures), error: String(error) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
await delay((config?.pollSeconds ?? 30) * 1000, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the queue after the cursor, take in each message, then move the cursor past them. */
|
||||
export async function pollQueue(ctx: SourceContext, signal: AbortSignal): Promise<{ read: number; unreadable: number }> {
|
||||
const after = ctx.state.get(KV.webhook.cursor) ?? 0
|
||||
const output = await ctx.commands.withSignal(signal).run('hookq', ['pull', '--after', String(after), '--json'])
|
||||
const page = PageSchema.parse(JSON.parse(output))
|
||||
let unreadable = 0
|
||||
for (const item of page.items) {
|
||||
if (signal.aborted) throw new Error('The webhook Source stopped during the read')
|
||||
const message = MessageSchema.safeParse(item)
|
||||
if (!message.success) { unreadable++; continue }
|
||||
const trigger = webhookTrigger(message.data, page.me)
|
||||
if (!trigger) continue
|
||||
await ctx.receive(trigger)
|
||||
const conversation = { label: { zh: '对话', en: 'Conversation' }, value: message.data.conversation }
|
||||
for (const ref of trigger.threads) ctx.threads.describe(threadKey(ref), { facts: { conversation } })
|
||||
}
|
||||
ctx.state.set(KV.webhook.cursor, page.next)
|
||||
return { read: page.items.length, unreadable }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
- **Setup and the switch.** Wait for `ctx.ready`, and read nothing while `ctx.configured()` is false. Read `ctx.config(kind)` on every round: it is undefined while the Source is off.
|
||||
- **Cursors.** Store read positions in `ctx.state` using typed entries in `KV` (`src/kv.ts`), such as `webhook: {cursor: record<number>('webhook-cursor')}`. `tests/store.spec.ts` requires key literals to appear only in that registry. External packages can define keys with exported `record` and `family`. Setting `undefined` removes a record; `ctx.atomic` groups writes in a transaction.
|
||||
|
||||
Advance a cursor only after every covered Trigger has been stored. An interrupted round leaves the cursor unchanged; stable ids make replay safe. For timestamp cursors, `cursorAt(started)` from `src/sources/poll.ts` subtracts five seconds to allow for late remote writes.
|
||||
- **Thread metadata.** After receiving a Trigger, call `ctx.threads.describe(key, {label, url, facts})` to populate its display information. `ctx.threads.patch(key, meta)` delegates to `threads.patchMeta`, setting Source-owned keys or removing them when the value is `undefined`. Both ignore absent Threads, including those of filtered Triggers.
|
||||
- **Per-item errors.** Count malformed items and continue rather than failing the entire batch. `validItems(item, list, tally)` in `src/infra/commands.ts` supports this pattern. For independently polled Threads, use `pollThreads` from `src/sources/poll.ts`; it records each failure as `pollError`. Its `polled` predicate keeps live subscriptions and a two-week window after the later of the last Trigger and Ticket end.
|
||||
- **Health.** Write only declared entries through `ctx.report`. `health(state, code, values)` provides fixed messages such as `setupPending`, `sourceOff` and `pollFailures`. Count consecutive failures before reporting `error`.
|
||||
- **Abort.** Pass the signal to `ctx.commands.withSignal(signal)` and to `delay`, and check it between items, so a stop or a restart ends the loop at once.
|
||||
|
||||
This example does not send Source-originated notices. A Source that needs them calls `ctx.notify`; Outbox delivers them like other replies.
|
||||
|
||||
It also does not bind Threads to Ticket completion. Use `ctx.tickets.bindThread` and `threadClosed` only when a Source's Thread lifecycle determines whether work is done, as GitHub pull requests do. The Engine still decides when the Ticket ends.
|
||||
|
||||
### 10. Register the plugin
|
||||
|
||||
Build the module as its own entry in `tsdown.config.ts`:
|
||||
|
||||
```ts
|
||||
entry: { index: 'src/index.ts', 'github-watch': 'src/sources/github.ts', 'feishu-watch': 'src/sources/feishu.ts',
|
||||
'webhook-watch': 'src/sources/webhook.ts' },
|
||||
```
|
||||
|
||||
Then export the built file in `package.json` by adding `"./webhook-watch": "./lib/webhook-watch.js"` to `exports`, and add the plugin to the `insert` list of the bundle's `cordis.patch.yml`, disabled, as Feishu is:
|
||||
|
||||
```yaml
|
||||
- id: dsh-superagent-webhook
|
||||
name: dsh-superagent/webhook-watch
|
||||
disabled: true
|
||||
```
|
||||
|
||||
Enable the plugin in a profile's `cordis.patch.yml` with `id: dsh-superagent-webhook` and `disabled: false`. Build and restart. The new Source appears in Sources settings and under **Eval → On each Source**.
|
||||
|
||||
Add its module and spec to the module and test tables in [007 Architecture](007-architecture.md). `tests/docs.spec.ts` checks that both indexes are complete.
|
||||
|
||||
### 11. Tests
|
||||
|
||||
Create `tests/webhook.spec.ts` using the pattern in `tests/sources.spec.ts`: a real Store in a temporary directory, a `SourceRegistry` with `HealthBoard`, an Engine with stand-in ports, and a `SourceContext` from `sourceContextOf`.
|
||||
|
||||
Mock CLI calls by spying on `Commands.run`. Also spy on `withSignal`, because it normally returns a new `Commands` instance.
|
||||
|
||||
```ts
|
||||
function host(overrides: Partial<SourceContextDeps> = {}) {
|
||||
const store = new Store(join(mkdtempSync(join(tmpdir(), 'webhook-')), 'db.sqlite'))
|
||||
store.saveSettings(SettingsSchema.parse({
|
||||
sources: { webhook: { enabled: true, config: { conversations: [{ id: 'ops', listen: 'mentions' }] } } },
|
||||
policy: { sources: { webhook: { allowed: [{ id: 'alice' }] } } },
|
||||
}))
|
||||
// The registry claims the kind's health entries on the board, where the test reads them.
|
||||
const board = new HealthBoard()
|
||||
const sources = new SourceRegistry(() => store.settings(), board)
|
||||
sources.register(webhookKind)
|
||||
const engine = new Engine({ repo: store, scope: sources, policy: () => settingsPolicy(store.settings(), sources),
|
||||
assessor: { request: vi.fn() }, executor: { start: vi.fn(), deliver: vi.fn(), stop: vi.fn() },
|
||||
outbox: { send: vi.fn(), status: () => undefined, withdraw: vi.fn() } })
|
||||
const commands = new Commands(() => testDeployment(), {}, new AbortController().signal)
|
||||
const receive = vi.fn((trigger: Trigger) => { const result = engine.receive(trigger); engine.evaluateDue(); return result })
|
||||
// The Source's own notices go to the outbox, which keeps their receipts and sends nothing here.
|
||||
const deps: SourceContextDeps = { store, engine, outbox: { send: message => { store.receipts.add(message) } }, commands, sources,
|
||||
deployment: testDeployment(), ready: Promise.resolve(), configured: () => true, receive, ...overrides }
|
||||
return { ...deps, store, engine, commands, receive, health: board.all, ctx: sourceContextOf(deps, webhookKind) }
|
||||
}
|
||||
/** The queue as `hookq` answers it. */
|
||||
function queue(h: ReturnType<typeof host>, answer: () => unknown) {
|
||||
vi.spyOn(h.commands, 'withSignal').mockReturnValue(h.commands)
|
||||
return vi.spyOn(h.commands, 'run').mockImplementation(async () => JSON.stringify(answer()))
|
||||
}
|
||||
|
||||
it('takes in what the queue holds after the cursor, once, and moves the cursor after it', async () => {
|
||||
const h = host()
|
||||
queue(h, () => ({ me: 'agent', items: [message('m1'), { id: 'broken' }, message('m2', { conversation: 'elsewhere' })], next: 3 }))
|
||||
expect(await pollQueue(h.ctx, new AbortController().signal)).toEqual({ read: 3, unreadable: 1 })
|
||||
expect(await h.receive.mock.results[0]!.value).toBe('stored')
|
||||
expect(await h.receive.mock.results[1]!.value).toBe('filtered')
|
||||
expect(h.store.get(KV.webhook.cursor)).toBe(3)
|
||||
// An allowed sender who mentions the agent makes an explicit request.
|
||||
expect(h.engine.candidate(key('webhook', 'm1'))?.strength).toBe('explicit')
|
||||
await pollQueue(h.ctx, new AbortController().signal)
|
||||
expect(await h.receive.mock.results[2]!.value).toBe('duplicate')
|
||||
h.store.close()
|
||||
})
|
||||
```
|
||||
|
||||
`message(id, fields)` builds a queue item. Cover at least:
|
||||
|
||||
- the translation: a stable id, the Thread, the actor, `human` and `addressed`, and no Trigger for the agent's own message;
|
||||
- a round: Triggers stored once, one outside the scope `filtered`, the cursor moved after them, an allowed sender's mention evaluated as `explicit`;
|
||||
- a round cut short, which keeps its cursor;
|
||||
- `send`: `sent`, `gone` for a 410 only, and a throw for any other failure, with the CLI's error built by `commandError`;
|
||||
- the Thread context: the messages of a page and the cursor of the next, as `tests/registry.spec.ts` reads a kind's Threads through `SourceRegistry.threadContext`;
|
||||
- the plugin: `apply` registers the kind and reports `setupPending` while setup waits, its disposers unregister the kind, and a failure that escapes the loop becomes the Source's health instead of an unhandled rejection;
|
||||
- the Source's part of the Policy and its admission rows, as `tests/registry.spec.ts` and `tests/policy.spec.ts` check the Feishu Source's.
|
||||
|
||||
### Checklist
|
||||
|
||||
1. Map Triggers, Threads, actors, Channel addresses and receiving scopes.
|
||||
2. Write the kind: `schema`, `fields`, `health`, and `facts` if setup derives any.
|
||||
3. Translate events into Triggers with stable ids, and drop the agent's own.
|
||||
4. Decide what the Source receives in `scope`, from `meta` alone.
|
||||
5. Give it a Channel whose `send` posts each message once and answers `gone` only when the conversation can never receive it.
|
||||
6. Give assessments its Thread context, if it can read a Thread.
|
||||
7. Give it a part of the Policy with `allowed` and the admission rows.
|
||||
8. Register its execution environment variables.
|
||||
9. Write the plugin entry and a loop that waits for setup, follows the switch, moves cursors after `receive`, keeps errors per item, reports health and stops on abort; name the cursors in `KV`.
|
||||
10. Add the build entry, the export and the `cordis.patch.yml` insert, and list the module and its spec in the tables of 007.
|
||||
11. Test it as `tests/sources.spec.ts` tests the other Sources.
|
||||
Reference in New Issue
Block a user