Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ This section lists what is on `main` and not yet on npm.
- `SlackTriggerAdapter`, `CronTriggerAdapter` and `WebhookTriggerAdapter` (`@lousho/build-ai-agent/triggers`) are deprecated and will be removed in a future major version. Use `slackChannel()` for Slack (sessions per thread, approval buttons), `defineSchedule()` with `startSchedules()` for cron (or `schedules/` in an agent directory, or cron triggers in a spec file), and `webhookChannel()` for webhooks, each mounted with `mountChannels()`. `verifySlackSignature()`, the `WebhookAuth` helpers, `parseCronExpression()`, `TriggerAdapter` and `TriggerRegistry` are not deprecated. Their options types (`SlackTriggerAdapterOptions`, `CronTriggerAdapterOptions` with `CronIntervalOptions` and `CronExpressionOptions`, `WebhookTriggerAdapterOptions`) and `WebhookTriggerHandle` carry `@deprecated` too. Nothing changes at run time. See docs/triggers.md.

### Fixed
- A channel button click (Slack, Discord), inline-keyboard tap (Telegram), Adaptive Card press (Teams) or `/approve` command (GitHub) after a restart is now bound to the conversation's session before the approval is resolved, so its continuation is appended to the session transcript again (#279). The decision names the conversation it came from (`inbound`); with checkpointed sessions (`mountChannels` `store` with `checkpoints`, e.g. a `SqliteStore`) it is accepted only when that session's pending turn waits on exactly that approval id and kind - a decision for another conversation, a replay of a decided one and a decision of the wrong kind are refused (`LOUSHO_APPROVAL_NOT_FOUND`) before the approval is touched, and `onDecision` audits only decisions that pass, so a replayed click cannot run the tool twice and the thread's next message continues the same session. Without a checkpoint store nothing can be checked, so the approval store alone decides, as before.
- A channel's function `approvers` works after a restart (#280): `ctx.approval(id)` asked only this process's `agent.approvals.list()`, so a pause the restarted process did not make looked the same as a denial and the function failed closed. `ApprovalStore` gains an optional `load(id)` - `resolve()` without removing the record; every shipped store implements it, and a custom store without it keeps compiling and behaves as before - and the new `agent.approvals.get(id)` reads through it. `ctx.approval` falls back to that, so the function sees the same request (`toolName`, `input`, `sessionId`, `principal`) it saw before the restart and fails closed only when no store knows the id (a process-local approval store still forgets its pauses at a restart).
- `lousho` CLI surface (LOU-R19): `lousho dev`/`lousho chat --traces` now writes traces for a `.ts`/`.js` module that exports an already-built `createAgent()` agent - the module's export could not take the flag's exporter after the fact, so nothing was written. `createAgent()` remembers the config it was built with (readable via the internal `createAgentConfigOf()`), and the loader rebuilds the agent with the flag's exporter (the module's own `exporter` is replaced while the flag is set; a module exporting an agent not made by `createAgent()` prints a warning and keeps its own). A spec path that does not exist (e.g. `lousho chat missing.yaml`) now fails with the coded `LOUSHO_SPEC_NOT_FOUND` instead of a raw ENOENT. `lousho add --dry-run` no longer fails when a target file already exists; it prints the file list with an "(exists - a real install needs --overwrite)" marker and exits 0 (the symlink checks still run). Top-level `lousho --help` lists the implemented flags again: `--no-schedules`/`--traces` on `dev` and `chat`, `eval`'s record/replay/drift/`--config` flags, `init`'s `--sdk-path`, and `build`'s positional form.
- A cassette failure keeps its type through `agent.send()` (LOU-R13): a replay mismatch (or a missing cassette raised by the provider-interception seam that `lousho eval` installs) was compacted into a generic `CompactedLLMProviderError`, losing the cassette path, the call number and the re-record hint. `CassetteMismatchError` is now an `SDKError` with code `LOUSHO_CASSETTE_INVALID` (docs/errors.md already claimed cassette errors carry it), and the run loop rethrows cassette errors untouched instead of compacting them, so `send()` rejects with the `CassetteMismatchError` itself (`.cassette`, `.callNumber`). `setProviderInterceptor()` and the `ProviderInterceptor` type are now exported from `@lousho/build-ai-agent/testing`, the public entry for the model-boundary seam docs/evals.md describes.
- A tool call approved with `agent.approvals.resolve()` (or `resumeAfterApproval()`) now has its `execute_tool` span (#281). It ran before the continued run started, outside any span, so OpenTelemetry and `fileTraceExporter()` showed the continued run's `invoke_agent` and `chat` spans but not the approved tool. The continued run's `invoke_agent` span now opens first and the approved call runs inside it: its `execute_tool` span is a child of it (a `run_code` call's inner calls and a sub-agent the tool starts are children of the tool span), with the same `captureContent` / `redactContent` rules as any tool span, and no span is made for a rejected call. A pause that happens again (sign-in) leaves the `invoke_agent` span with the tool span and no `chat`.
Expand Down
11 changes: 11 additions & 0 deletions apps/agent-forge/server/approvalStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,15 @@ export class FileApprovalStore implements ApprovalStore {
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8')) as ResolvedApproval;
}

/** `ApprovalStore.load` (#280): like `resolve`, it scans every agent's approvals directory for the id, but does not delete. */
async load(approvalId: string): Promise<ResolvedApproval | null> {
const agentsDir = path.join(this.baseDir, '.lousho', 'agents');
if (!fs.existsSync(agentsDir)) return null;
for (const agentId of fs.readdirSync(agentsDir)) {
const found = await this.peek(agentId, approvalId);
if (found) return found;
}
return null;
}
}
13 changes: 9 additions & 4 deletions docs/approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ task.
- `agent.approvals.list()` returns the pending calls this agent paused on in
this process, oldest first: `{ id, toolCallId, toolName, args, createdAt }`,
plus `subagentPath` when the call belongs to a sub-agent.
- `agent.approvals.get(id)` returns one pending call without deciding it:
`list()`'s entries first, then - with an `approvalStore` that implements
`load()` - a pause saved before a restart, `undefined` when the id is
unknown or already resolved.
- `agent.approvals.resolve({ id, approved, note? })` runs the call (approved)
or gives the model a rejection with your `note` (rejected), continues the
run, and resolves with the continued run's result, which may pause again.
Expand All @@ -219,10 +223,11 @@ const store = new SqliteStore('./.lousho/agent.db');
const agent = createAgent({ provider, tools: [emailTool], store }); // or approvalStore: store.approvals
```

`list()` only knows the pauses made by this agent object; keep the
`approvalId` (or read the store) to resolve a pause from somewhere else. A
continued run joins a session only when it is resolved through the agent that
owns that session object.
`list()` only knows the pauses made by this agent object; `get(id)` also finds
a pause the durable store still holds (for example one saved before a
restart). Keep the `approvalId` (or read the store) to resolve a pause from
somewhere else. A continued run joins a session only when it is resolved
through the agent that owns that session object.

### Resuming with a changed agent

Expand Down
50 changes: 30 additions & 20 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,11 @@ and the approval stays pending.
message author on Slack, the command's user on Discord) may approve. In
earlier versions anyone who could see the message could; `approvers: () => true`
restores that, which you should only do in a private channel. A function
`approvers` fails closed when the process does not know the pending call (after
a restart); use the list form or the default for approvals that must survive
one. Answers to an `ask_question` are not restricted: the next message (Slack)
`approvers` sees the pending call after a restart too: `ctx.approval(id)` asks
the durable approval store when this process did not pause on the id, so it
decides the same way it did before. Only with a process-local approval store
(the default `InMemoryApprovalStore` - then nothing survives a restart anyway)
does it still fail closed. Answers to an `ask_question` are not restricted: the next message (Slack)
or `/ask` (Discord) in the conversation is the answer. Who decided goes to
`mountChannels(agent, channels, { onDecision({ approver, decision, sessionId, channel }) {} })`.
The Telegram channel takes the same `approvers` (Telegram user ids as strings; a
Expand Down Expand Up @@ -281,8 +283,11 @@ with a `SqliteStore`, and the agent's `approvalStore`): the next message in the
thread is still the answer, the turn continues, and the question, the answer
and the reply are appended to the session transcript. A message in a thread
whose session waits on a tool approval does not decide it; only the buttons
do. After a restart, a button click's continuation is posted but not appended
to the session transcript.
do. A button click after a restart is bound to the thread's session first -
the click must name a thread whose checkpointed turn waits on exactly that
approval - so the continuation is appended to the session transcript, a
replayed click does not run the tool twice, and a click in another thread is
refused.

## Discord

Expand Down Expand Up @@ -348,8 +353,9 @@ Pending approvals are resolved from the button click and the approval store. As
on Slack, a pending `ask_question` survives a restart given durable stores for
sessions, checkpoints and approvals: the next `/ask` in the channel is the
answer and the continued turn is appended to the session transcript. A button
click's continuation after a restart is posted but not appended to the session
transcript.
click after a restart names its conversation, so it is bound to that session
before the approval is resolved - the continuation joins its transcript, and a
stale or copied click decides nothing.

An [agent directory](./agent-directories.md)'s `channels/*.ts` files are loaded
as channels too, and the node server mounts them. `SlackTriggerAdapter` (see [Triggers](triggers.md)), which replies
Expand Down Expand Up @@ -434,9 +440,10 @@ messages from the method name and status only: the token never reaches
Pending approvals are resolved from the tap and the approval store. As on Slack
and Discord, a pending `ask_question` survives a restart given durable stores
for sessions, checkpoints and approvals: the next message in the chat is the
answer and the continued turn is appended to the session transcript. A tap's
continuation after a restart is sent but not appended to the session
transcript.
answer and the continued turn is appended to the session transcript. As with a
button click on the other channels, a tap after a restart is checked against
the chat session's checkpointed turn, so the continuation joins the transcript
and a replayed tap does not run the tool twice.

## GitHub

Expand Down Expand Up @@ -515,8 +522,9 @@ before posting `/approve` is refused. It is a snapshot taken at the command, not
a live permission check; if a revocation must take effect inside that window,
use `approvers` with a function that asks the GitHub API (for example the
collaborator permission) before it returns `true`. A function `approvers`, as on
the other channels, fails closed when the process does not know the pending
call (after a restart).
the other channels, sees the pending call after a restart too (the durable
approval store is asked), and fails closed only with a process-local approval
store.

Set up a GitHub App:

Expand Down Expand Up @@ -568,11 +576,12 @@ call and the HTTP status only (`LOUSHO_CHANNEL_REQUEST_FAILED`).

Pending approvals are resolved from the command comment and the approval store.
A pending `ask_question` is kept in memory until the next comment, and survives
a restart given durable stores, as on the other channels. After a restart, a
command with an id that is no longer pending (decided already, expired) fails
with the generic "Sorry, that request failed." comment instead of being ignored,
and an id is no longer tied to the thread it was posted in. A continuation after
a restart is posted but not appended to the session transcript.
a restart given durable stores, as on the other channels. After a restart the
command names its thread, so - given durable checkpoints - it is bound to that
thread's session before the approval is resolved: the continuation is appended
to the session transcript, and an id the thread is not waiting on (decided
already, or the approval of another thread) fails with the generic "Sorry,
that request failed." comment.

## Microsoft Teams

Expand Down Expand Up @@ -658,6 +667,7 @@ http.createServer((req, res) => {
The app password and the access token are never put in an error message or log
line: a failed call is reported to `onError` with the call and the HTTP status
only (`LOUSHO_CHANNEL_REQUEST_FAILED`). A press on a card works on a restarted
process, because it names the conversation and the approval itself; the
continuation after a restart is sent but not appended to the session
transcript.
process, because it names the conversation and the approval itself; given
durable checkpoints the press is bound to that session's pending turn first,
so the continuation is appended to the session transcript and a press a
session is not waiting on decides nothing.
Loading
Loading