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
9 changes: 9 additions & 0 deletions docs/architecture/agent-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
DeepChat 与 ACP 的执行路径对比见
[deepchat-vs-acp-agents/](./deepchat-vs-acp-agents/)。

当前消息与权限边界由以下维护合同定义:

- [Send Message Canonical Boundary](./send-message-canonical-boundary/spec.md):route/application
compatibility 与 runtime canonical input 的分界;
- [Permission Mode Policy Ownership](./permission-mode-policy-owner/spec.md):assignment、storage 与
deferred execution 的权限策略 owner;
- [Provider Round Stop Contract](./provider-round-stop-contract/spec.md):AI SDK、ACP 与内部 provider
round terminal reason 的无损映射。

## Agent 类型与路由

DeepChat 支持两个 executable descriptor kind:
Expand Down
83 changes: 83 additions & 0 deletions docs/architecture/permission-mode-policy-owner/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Permission Mode Policy Ownership

## Status

Implemented and validated.

## Problem

Before this contract was introduced, permission mode passed through several unrelated functions
named `normalizePermissionMode`. Those functions hid two different policies:

- session assignment treats a missing value as `full_access`;
- runtime settings and deferred tool execution treat a missing or unknown value as `default`.

The same value was defaulted again after assignment, persistence, hydration, and tool execution.
That weakened the internal contract and allowed deferred execution to use a stale or invented
permission value.

## Goal

Give each policy one explicit owner:

- assignment is the only owner of the `full_access` default for omitted create, ACP draft, subagent,
and transfer configuration;
- persistence decoding owns validation of stored permission values and uses `default` for missing
or invalid stored data;
- the runtime initialization boundary owns its historical `default` fallback for direct callers;
- agent-context updates, settings updates, and deferred execution accept and propagate an exact
`PermissionMode` without normalization.

## Acceptance Criteria

- No function named `normalizePermissionMode` remains in main-process code.
- Assignment resolves an omitted permission mode to `full_access` and preserves all three explicit
modes.
- Persistence decoding preserves all three valid modes and resolves missing or invalid stored data
to `default`.
- Application lifecycle initialization always supplies the assignment policy's exact
`PermissionMode`; the lower-level runtime boundary keeps the existing `default` behavior for
direct callers that omit it.
- Agent-context replacement requires `PermissionMode` in its internal TypeScript contract.
- `setPermissionMode` writes the exact validated mode it receives.
- Deferred tool execution loads the session state after asynchronous tool preparation and uses that
latest permission mode without inventing a fallback.
- Persistence create APIs require an exact `PermissionMode`; they cannot default omission to
`full_access`.
- Existing renderer routes and user-visible behavior remain unchanged.
- Security-focused tests cover assignment defaulting, persistence decoding, exact runtime updates,
and deferred execution from hydrated state.

## Constraints

- Keep `PermissionMode` as the existing union: `default | auto_approve | full_access`.
- Do not add a policy class, registry, dependency, or shared utility module.
- Do not modify renderer code.
- Keep create and assignment behavior at `full_access` when no mode is configured.
- Keep the conservative persisted-data fallback at `default`.

## Non-Goals

- Changing the meaning of any permission mode.
- Changing the renderer permission selector or route payloads.
- Redesigning per-tool permission grants or auto-approval review.
- Migrating existing valid database rows.
- General cleanup of other normalization helpers.

## Decisions

1. Assignment defaulting remains a small named function inside `agentAssignmentPolicy.ts`; it is a
policy, not a reusable normalizer.
2. Stored data is decoded at `DeepChatSessionsTable.get`, the first point where an untrusted
database string enters the typed domain.
3. Runtime state never represents a missing permission mode. The low-level initialization boundary
resolves an omitted direct input to `default`; application lifecycle callers already supply the
assignment result. Context update contracts make the field required.
4. Deferred execution loads session state immediately before tool invocation so a permission change
made during asynchronous preparation takes effect.
5. Agent configuration merging preserves an omitted mode; only assignment resolves it to
`full_access`. Persistence create APIs require the resolved value.

## Open Questions

None.
101 changes: 101 additions & 0 deletions docs/architecture/provider-round-stop-contract/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Provider Round Stop Contract — Spec

## Status

Implemented and validated.

## Problem

Before this contract was introduced, provider terminal reasons crossed multiple lossy translation
layers before the runtime settled a turn:

1. AI SDK or ACP emits its protocol-specific reason.
2. A provider adapter maps it to `StopStreamEvent.stop_reason`.
3. `accumulator.ts` maps the already-typed reason again.
4. terminal settlement maps or overwrites the accumulated reason.

Those layers hid invalid or bounded outcomes:

- AI SDK `content-filter`, `other`, and unknown values become `complete`;
- AI SDK `abort` stream parts are ignored;
- `stop_sequence` exists only as an intermediate value and becomes `complete` later;
- ACP `max_turn_requests` becomes `stop_sequence`, then `complete`;
- ACP compatibility settlement overwrites `max_tokens` with `complete`;
- `StreamState` starts at `complete`, so a stream without a terminal event can succeed;
- `StreamState.stopReason` contains an `abort` value that is never assigned.

## Goal

Translate each external protocol exactly once at its provider boundary, carry one closed provider
round reason through the accumulator, and preserve the resulting terminal reason through runtime and
ACP compatibility settlement.

## Protocol Mappings

AI SDK reasons are converted once at the stream adapter boundary:

| AI SDK input | Internal result |
| --- | --- |
| `stop` | `complete` |
| `length` | `max_tokens` |
| `tool-calls` | `tool_use` |
| `content-filter` | error event + `error` stop |
| `error` | error event + `error` stop |
| `other` | error event + `error` stop |
| abort stream part | error event + `error` stop |

A parsed legacy tool call may replace only a normal `stop` with `tool_use`; it cannot override a
bounded or failed finish. A matching local abort signal remains the sole owner of `user_stop`.

ACP reasons are converted once at the ACP content boundary:

| ACP input | Internal result |
| --- | --- |
| `end_turn` | `complete` |
| `max_tokens` | `max_tokens` |
| `max_turn_requests` | `max_turn_requests` |
| `refusal` | error event + `error` stop |
| `cancelled` | error event + `error` stop |

An unsupported runtime stop reason produces an error event and `error` stop. The exhaustive switch
still fails compilation when the SDK adds a declared reason that has not been mapped.

## Acceptance Criteria

- `StopStreamEvent.stop_reason` uses one exported closed type.
- The internal provider round reasons are `complete`, `tool_use`, `max_tokens`,
`max_turn_requests`, and `error`.
- `stop_sequence` is removed from the internal event contract.
- `mapStopReason` is deleted and the accumulator assigns the typed reason directly.
- `StreamState.stopReason` starts as `null` and is reset before every provider round.
- A provider stream that ends without a terminal event settles as `provider_error`.
- AI SDK finish and abort parts are handled explicitly without a default-success branch.
- AI SDK `content-filter`, `error`, `other`, and an abort without a matching local abort signal
settle as provider errors with a useful message.
- A local abort signal remains the sole source of the `user_stop` run outcome.
- ACP `max_turn_requests` and `max_tokens` survive compatibility projection settlement.
- ACP `refusal` and protocol-level `cancelled` settle as provider errors. They do not claim a local
user stop without a matching local abort signal.
- Closed protocol enums use exhaustive switches without `default` branches.
- Focused tests cover every AI SDK finish reason, AI SDK abort, every ACP stop reason, accumulator
assignment, missing terminal events, and ACP settlement preservation.

## Constraints

- Do not change renderer code or route payloads.
- Do not introduce a terminal-state framework, class hierarchy, adapter registry, or dependency.
- Keep provider-round reasons separate from run-level reasons such as `user_stop`,
`max_tool_calls`, `context_window`, and `provider_error`.
- Preserve existing tool-loop behavior for `tool_use` and token-limit behavior for `max_tokens`.
- Keep raw ACP turn persistence unchanged; it already stores the protocol reason.

## Non-goals

- Unifying all `ProcessResult.stopReason` strings in this iteration.
- Removing the ACP provider compatibility path.
- Refactoring provider request tracing, usage accounting, or tool execution.
- Changing user-visible error presentation beyond exposing previously swallowed provider failures.

## Open Questions

None.
105 changes: 105 additions & 0 deletions docs/architecture/send-message-canonical-boundary/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Send Message Canonical Boundary

## Status

Implemented and validated.

## Problem

The typed chat routes accept the compatibility input `string | SendMessageInput`, and session
creation has a separate legacy shape. Before this contract was introduced, the same live turn was
normalized again inside the runtime presenter, turn coordinator, context construction, and ACP
compatibility projection after the session application layer had already converted it.

Those repeated conversions make each downstream module distrust its caller. They also hide the
actual ownership boundary: route and session-creation compatibility belongs to the session
application layer, while runtime ports should operate on one canonical object shape.

Pending queue and update ports now use the same canonical object below the application boundary.
The store serializes that object directly; persisted payloads are decoded with
`SendMessageInputSchema`. Invalid JSON or shape is reported as corrupt storage and degrades to the
raw payload as user text so one damaged record cannot block the pending queue.

## Goal

Make the session application turn coordinator the canonical boundary for live send, live steer,
and the initial session turn. After that boundary, runtime handles, backend ports, turn execution,
context construction, and ACP prompt resources consume `SendMessageInput` directly without
normalizing it again.

## Canonical Data Flow

```text
chat route string | SendMessageInput
-> SessionTurnCoordinator.normalizeSendMessageInput()
-> AgentSessionSendInput.content: SendMessageInput
-> backend/runtime/turn/context: SendMessageInput

session create input
-> SessionLifecycleCoordinator.normalizeCreateSessionInput()
-> SessionInitialTurnInput.content: SendMessageInput
-> AgentSessionSendInput.content: SendMessageInput
-> backend/runtime/turn/context: SendMessageInput
```

## Acceptance Criteria

1. Chat route names, input schemas, output schemas, renderer behavior, and string compatibility are
unchanged.
2. `SessionTurnCoordinator` converts live send and live steer input exactly once before resolving a
runtime session.
3. `SessionLifecycleCoordinator` remains the sole converter for the initial create-session input;
`SessionTurnCoordinator.startInitialTurn` does not normalize the result again.
4. `AgentSessionSendInput.content` and the live steer facets use `SendMessageInput`, not
`string | SendMessageInput`.
5. Pending queue and update facets below the application boundary also use `SendMessageInput`.
6. Pending storage accepts only canonical objects. Invalid persisted JSON or shape is logged and
degrades to raw user text so queue processing remains continuous.
7. DeepChat turn execution and ACP prompt resource resolution receive `SendMessageInput` directly.
8. Context construction does not normalize the current live user input. Persisted history decoding
remains separate and unchanged.
9. The main-process live send, live steer, and initial-turn tests prove that text, files, active
skills, and inline items arrive at the runtime in canonical form.
10. No new manager, service, framework, dependency, renderer code, database schema, or migration is
introduced.

## Constraints

- Preserve the current normalization semantics at the application boundary: string input becomes
`{ text, files: [] }`; active skills are trimmed and deduplicated; falsey file entries are removed;
empty optional arrays are omitted.
- Keep persisted pending payload decoding at the storage boundary.
- Keep persisted user-message decoding tolerant in this iteration. It is a storage boundary, not a
live-turn contract.
- Keep direct calls to the legacy `AgentRuntimePresenter.processMessage` test and compatibility
facade working while ensuring canonical production calls are not re-normalized.

## Non-Goals

- Changing queue ordering, recovery, limits, or scheduling behavior.
- Changing permission mode, provider stop reason, cancellation, generation settings, or error
behavior.
- Changing the public route compatibility union.
- Introducing a branded or second canonical message-input type.
- Cleaning unrelated normalizers in message history, compaction records, migrations, or renderer
code.

## Decisions

- Reuse the existing `SendMessageInput` type. A second type would add conversion code without adding
a trust boundary.
- Reuse `normalizeSendMessageInput` only at the session application boundary. Downstream code may
validate business rules such as non-empty content, but it must not reshape the object.
- Retain a string-only compatibility conversion at the public `AgentRuntimePresenter` facade because
it is called directly by existing main-process integrations. Object inputs pass through unchanged;
internal runtime ports remain canonical.
- Narrow pending queue and update operations after the companion persistence-decoder work proved the
supported stored format and removed write-side coercion.
- Decode persisted pending payloads with `SendMessageInputSchema`; on failure, report the integrity
error and preserve the raw payload as user text because conversation continuity takes precedence
over reconstructing optional structure.
- GitHub issue synchronization is not part of this work.

## Open Questions

None.
65 changes: 0 additions & 65 deletions docs/issues/agent-terminal-outcome-consistency/spec.md

This file was deleted.

2 changes: 1 addition & 1 deletion src/main/agent/acp/instance/acpAgentInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class AcpAgentInstance
})
}

async send(content: string | SendMessageInput): Promise<MessageStartResult> {
async send(content: SendMessageInput): Promise<MessageStartResult> {
if (this.closed) throw new Error(`ACP session ${this.sessionId} is closed`)
if (this.active) throw new Error(`ACP session ${this.sessionId} is already generating`)

Expand Down
Loading
Loading