@borgee/agents-host 0.2.73 → 0.2.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -30
- package/dist/agents-host-supervisor.d.ts +1 -0
- package/dist/agents-host-supervisor.js +3 -1
- package/dist/agents-host.d.ts +6 -4
- package/dist/agents-host.js +117 -16
- package/dist/background-runs.d.ts +23 -0
- package/dist/background-runs.js +153 -0
- package/dist/chat/chat-control-plane.d.ts +6 -1
- package/dist/chat/sdk-chat-control-plane.d.ts +4 -2
- package/dist/chat/sdk-chat-control-plane.js +7 -0
- package/dist/context/claude-file-brief.js +3 -3
- package/dist/context/injection.d.ts +18 -12
- package/dist/context/injection.js +53 -53
- package/dist/context/prompt.js +10 -10
- package/dist/context/resolved-working-folder.d.ts +3 -0
- package/dist/context/resolved-working-folder.js +106 -0
- package/dist/context/turn-preparation.js +1 -1
- package/dist/local-config.d.ts +7 -0
- package/dist/local-config.js +158 -12
- package/dist/managed-daemon.js +42 -35
- package/dist/plugin-sdk.js +179 -9
- package/dist/plugin-sdk.js.map +3 -3
- package/dist/policy/authorization-audit.d.ts +1 -1
- package/dist/policy/copilot-permission.d.ts +9 -0
- package/dist/policy/copilot-permission.js +120 -1
- package/dist/progress-to-activity.d.ts +1 -1
- package/dist/progress-to-activity.js +1 -0
- package/dist/providers/claude/adapter.d.ts +1 -1
- package/dist/providers/claude/adapter.js +7 -0
- package/dist/providers/claude/cli-client.js +4 -3
- package/dist/providers/codex/adapter.d.ts +1 -1
- package/dist/providers/codex/adapter.js +7 -0
- package/dist/providers/codex/cli-client.js +8 -7
- package/dist/providers/codex/project-doc.js +6 -6
- package/dist/providers/copilot/adapter.d.ts +6 -2
- package/dist/providers/copilot/adapter.js +27 -1
- package/dist/providers/copilot/cli-client.d.ts +37 -10
- package/dist/providers/copilot/cli-client.js +751 -122
- package/dist/providers/copilot/sdk-session.d.ts +149 -0
- package/dist/providers/copilot/sdk-session.js +981 -0
- package/dist/providers/create-provider.js +33 -0
- package/dist/providers/provider-adapter.d.ts +16 -1
- package/dist/providers/provider-adapter.js +13 -0
- package/dist/types.d.ts +32 -11
- package/package.json +3 -2
- package/skills/borgee-agent/references/task-properties.md +3 -3
- package/skills/borgee-agent/scripts/borgee-agent.mjs +1 -1
- package/skills/borgee-agent/scripts/borgee-agent.py +1 -1
- package/dist/context/resolved-workspace.d.ts +0 -3
- package/dist/context/resolved-workspace.js +0 -106
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ Rollback and hardening controls stay explicit:
|
|
|
101
101
|
- `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=<comma-list>` disables shipped gates for rollback
|
|
102
102
|
- `AGENTS_HOST_INTERNAL_PROVIDER_IMPLEMENTATIONS=copilot:v1` forces Copilot back to the v1 adapter while its v2 gate stays enabled; stale `claude:v1|v2` entries are tolerated as no-ops for compatibility
|
|
103
103
|
- `AGENTS_HOST_INTERNAL_POLICY_MODE=enforce` hardens the shipped `policy-audit-enforcement` gate beyond its default `audit-only` mode
|
|
104
|
-
- `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown` keeps every provider
|
|
104
|
+
- `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown` keeps every provider runtime process resident again; `PROVIDER_IDLE_SHUTDOWN_MINUTES=0` does the same without touching gates
|
|
105
105
|
|
|
106
106
|
Or with the CLI:
|
|
107
107
|
|
|
@@ -140,7 +140,7 @@ and resumable in that case.
|
|
|
140
140
|
|
|
141
141
|
Each managed runtime root keeps:
|
|
142
142
|
|
|
143
|
-
- `agents-host.yaml
|
|
143
|
+
- `.generations/<uuid>/` as validated immutable config generations; Unix and macOS publish `current`, `agents-host.yaml`, and `agents/` symlinks, while Windows publishes the active generation through the atomically replaced ordinary `current.json` pointer and resolves the generation internally without requiring Developer Mode or elevation
|
|
144
144
|
- `managed-runtime-settings.json` as the persisted managed runtime surface snapshot
|
|
145
145
|
- `daemon.log` as the detached managed daemon stdout/stderr log file
|
|
146
146
|
- `.state/` as the per-agent runtime state base
|
|
@@ -239,11 +239,11 @@ Notes:
|
|
|
239
239
|
| `RUNTIME_PROVIDER` | `--provider` | no | `claude` | `claude`, `copilot`, or `codex` |
|
|
240
240
|
| `CLAUDE_COMMAND` / `CLAUDE_ARGS` | `--claude-command` / `--claude-args` | no | `claude-agent-acp` / empty | Local Claude ACP adapter command + args. The shipped bundled `claude-agent-acp` path requires empty `CLAUDE_ARGS`; non-empty args are only valid when `CLAUDE_COMMAND` points at a custom ACP adapter command. Legacy `claude --print --permission-mode bypassPermissions` is tolerated as a compatibility alias. |
|
|
241
241
|
| `CODEX_COMMAND` / `CODEX_ARGS` | `--codex-command` / `--codex-args` | no | `codex-acp` / empty | Local Codex ACP adapter command + args. The default launch resolves the bundled `@agentclientprotocol/codex-acp` entrypoint. |
|
|
242
|
-
| `COPILOT_COMMAND` / `COPILOT_ARGS` | `--copilot-command` / `--copilot-args` | no | `copilot` / parsed but ignored
|
|
243
|
-
| `COPILOT_SESSION_TTL_MINUTES` | `--copilot-session-ttl-minutes` | no | `2880` | Idle TTL for per-channel Copilot
|
|
244
|
-
| `PROVIDER_IDLE_SHUTDOWN_MINUTES` | `--provider-idle-shutdown-minutes` | no | `10` | Idle window before the agent's shared
|
|
242
|
+
| `COPILOT_COMMAND` / `COPILOT_ARGS` | `--copilot-command` / `--copilot-args` | no | `copilot` / parsed but ignored | Local Copilot CLI command. The host resolves it to an executable and starts the SDK headless JSON-RPC runtime; legacy `COPILOT_ARGS` remain ignored. |
|
|
243
|
+
| `COPILOT_SESSION_TTL_MINUTES` | `--copilot-session-ttl-minutes` | no | `2880` | Idle TTL for per-channel Copilot SDK sessions |
|
|
244
|
+
| `PROVIDER_IDLE_SHUTDOWN_MINUTES` | `--provider-idle-shutdown-minutes` | no | `10` | Idle window before the agent's shared provider runtime **process** is shut down and re-spawned on the next turn. Applies to Claude, Codex, and Copilot. `0` keeps the process resident. Must be non-negative and no larger than the Node.js timer limit (~35791.39 minutes); anything else is rejected at startup. |
|
|
245
245
|
|
|
246
|
-
`COPILOT_SESSION_TTL_MINUTES` and `PROVIDER_IDLE_SHUTDOWN_MINUTES` are two different layers and do not replace each other. The first evicts an individual idle Copilot
|
|
246
|
+
`COPILOT_SESSION_TTL_MINUTES` and `PROVIDER_IDLE_SHUTDOWN_MINUTES` are two different layers and do not replace each other. The first evicts an individual idle Copilot SDK *session* inside a running runtime process. The second shuts the shared provider *process* itself down once every channel it hosts is idle, for all three providers, and takes every live session in that process with it. See [Conversation memory](#conversation-memory) for what happens to those sessions afterwards.
|
|
247
247
|
|
|
248
248
|
## Local-config mode
|
|
249
249
|
|
|
@@ -302,8 +302,7 @@ printf '%s' '{"host":{"borgeeBaseUrl":"https://borgee.example.com"},"agents":[{"
|
|
|
302
302
|
- if that post-publication pruning fails, the apply remains successful and its
|
|
303
303
|
JSON summary includes a `warnings` entry with code
|
|
304
304
|
`PRUNE_SUPERSEDED_GENERATIONS_FAILED`
|
|
305
|
-
- it uses a dedicated managed root: `<root>/agents-host.yaml` and
|
|
306
|
-
`<root>/agents` are stable links to the active private generation
|
|
305
|
+
- it uses a dedicated managed root: Unix and macOS expose `<root>/agents-host.yaml` and `<root>/agents` as stable links to the active private generation, while Windows uses `<root>/current.json` as the stable publication point and resolves the active generation internally
|
|
307
306
|
- it replaces stale supported agent config files as part of that atomic
|
|
308
307
|
full-set publication, never by pruning the active set first
|
|
309
308
|
- it creates and normalizes the managed root and generation directories to
|
|
@@ -349,12 +348,13 @@ The spec shape is:
|
|
|
349
348
|
always targets `<root>/agents`, and validation is re-run after generation using
|
|
350
349
|
the same local-config loader as `start --config` / `validate --config`. Start
|
|
351
350
|
the generated layout with `agents-host start --config <root>/agents-host.yaml`;
|
|
352
|
-
that
|
|
351
|
+
the managed loader treats that path as the stable logical entrypoint even when
|
|
352
|
+
Windows does not materialize a filesystem alias there.
|
|
353
353
|
The loader preserves that stable path for validation output and publication
|
|
354
354
|
watches, while pinning resolved host and agents-directory paths before reading.
|
|
355
355
|
Managed snapshots hold a short-lived reader lease until the read finishes, so
|
|
356
|
-
each snapshot uses one generation even if publication changes `current`
|
|
357
|
-
concurrently.
|
|
356
|
+
each snapshot uses one generation even if publication changes `current` or
|
|
357
|
+
`current.json` concurrently.
|
|
358
358
|
|
|
359
359
|
The supervisor loads:
|
|
360
360
|
|
|
@@ -479,14 +479,14 @@ successfully applied snapshot running.
|
|
|
479
479
|
Each Borgee channel is mapped 1:1 to a provider-native session while it stays
|
|
480
480
|
active:
|
|
481
481
|
|
|
482
|
-
- **Claude**: one persistent `@agentclientprotocol/claude-agent-acp` subprocess is shared by a single `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are serialized; different channels keep isolated ACP sessions. The real ACP session cwd now comes from the resolved channel
|
|
482
|
+
- **Claude**: one persistent `@agentclientprotocol/claude-agent-acp` subprocess is shared by a single `AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are serialized; different channels keep isolated ACP sessions. The real ACP session cwd now comes from the resolved channel working folder `~/.borgee/channels/<encoded-channel-id>/workspace`. Task threads only switch execution onto an explicit existing `execution.local_directory`; otherwise they stay discussion-only on `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP permission requests are denied so filesystem and shell tools stay unavailable. The host persists the channel→ACP-session map in its state root, restores same-host continuity with ACP `session/resume` when available, retries stale restores with a fresh session immediately, and injects the per-channel local context paths through Claude ACP `_meta.systemPrompt.append`.
|
|
483
483
|
- **Codex**: one persistent `@agentclientprotocol/codex-acp` subprocess is
|
|
484
484
|
shared by a single `AgentsHost`, with one ACP session per Borgee channel. When
|
|
485
485
|
the current turn has a materialized channel context payload, Codex refreshes a
|
|
486
486
|
same-directory `AGENTS.md` project document inside a provider-visible
|
|
487
487
|
per-channel projection directory and exposes that directory to Codex through
|
|
488
488
|
ACP `additionalDirectories`. The real ACP session cwd now comes from the
|
|
489
|
-
resolved channel
|
|
489
|
+
resolved channel working folder `~/.borgee/channels/<encoded-channel-id>/workspace`.
|
|
490
490
|
Task threads only switch execution onto an explicit existing
|
|
491
491
|
`execution.local_directory`; otherwise they stay discussion-only on
|
|
492
492
|
`~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP
|
|
@@ -494,35 +494,22 @@ active:
|
|
|
494
494
|
unavailable. The host persists the
|
|
495
495
|
channel→ACP-session map in its state root and attempts ACP `session/resume`
|
|
496
496
|
first when the adapter advertises it, otherwise `session/load`.
|
|
497
|
-
- **Copilot**: one persistent
|
|
498
|
-
`AgentsHost`, with one ACP session per Borgee channel. Same-channel turns are
|
|
499
|
-
serialized; different channels keep isolated ACP sessions. The real ACP
|
|
500
|
-
session cwd now comes from the resolved channel workspace
|
|
501
|
-
`~/.borgee/channels/<encoded-channel-id>/workspace` by default. Task threads
|
|
502
|
-
only switch execution onto an explicit existing `execution.local_directory`;
|
|
503
|
-
otherwise they stay discussion-only on
|
|
504
|
-
`~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and ACP
|
|
505
|
-
permission requests are denied so filesystem and shell tools stay
|
|
506
|
-
unavailable. The
|
|
507
|
-
host persists the channel→ACP-session map in its state root and restores
|
|
508
|
-
same-host continuity with ACP `session/resume` when available, otherwise
|
|
509
|
-
`session/load` plus local replay cleanup on runtimes that advertise only
|
|
510
|
-
load support.
|
|
497
|
+
- **Copilot**: one persistent SDK-managed headless Copilot subprocess is shared by a single `AgentsHost`, with one SDK session per Borgee channel. Same-channel turns are serialized; different channels keep isolated sessions. The real session cwd comes from the resolved channel working folder `~/.borgee/channels/<encoded-channel-id>/workspace` by default. Task threads only switch execution onto an explicit existing `execution.local_directory`; otherwise they stay discussion-only on `~/.borgee/channels/<encoded-thread-id>/scratch/workspace`, and SDK permission requests are denied so filesystem and shell tools stay unavailable. The host persists the channel→session map in its state root and restores same-host continuity through SDK session resume. A replacement human prompt uses the SDK's foreground-only interrupt and keeps background work alive; explicit stop interrupts the foreground, cancels background agents, and cancels active background shell tasks in that session.
|
|
511
498
|
|
|
512
499
|
We keep no transcript or history store of our own — only the native session id each provider should try to reopen next time. The transcripts themselves live in each CLI's own on-disk session store: `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for Claude, `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<timestamp>-<threadId>.jsonl` for Codex, and `~/.copilot/session-state/<guid>/` for Copilot. Because those stores are on disk rather than in the adapter process, a session can be reopened later by a *different* process on the same machine — which is what makes conversation memory survive both a host restart and the idle recycle below. Continuity is same-machine and same-`$HOME` only: another machine, or a different home directory, has no session to reopen.
|
|
513
500
|
|
|
514
501
|
### Idle adapter shutdown
|
|
515
502
|
|
|
516
|
-
Each of those subprocesses is shut down once every channel it hosts has been idle for `PROVIDER_IDLE_SHUTDOWN_MINUTES` (default `10`), and a fresh one is spawned lazily on the next turn. "Idle" means the host has no active turn, no queued turn, no
|
|
503
|
+
Each of those subprocesses is shut down once every channel it hosts has been idle for `PROVIDER_IDLE_SHUTDOWN_MINUTES` (default `10`), and a fresh one is spawned lazily on the next turn. "Idle" means the host has no active turn, no queued turn, no provider session start in flight, and nothing still being processed for any channel of that agent; a turn that arrives mid-shutdown waits for the teardown and then starts the replacement rather than failing. Only the provider subprocess is recycled — the agent stays connected to Borgee, the loopback gateway keeps running, and the agents-host process itself is untouched.
|
|
517
504
|
|
|
518
|
-
Conversation memory normally survives the recycle. Before the process goes away the host closes each live
|
|
505
|
+
Conversation memory normally survives the recycle. Before the process goes away the host closes each live provider session, and on the next turn it reopens the persisted session id against the CLI's on-disk store — Claude and Codex through ACP `session/resume`, Copilot through SDK session resume. This is the same restore path a host restart takes, and its ordinary outcome is that the model still remembers the conversation.
|
|
519
506
|
|
|
520
507
|
When a restore does fail, the run does not break: the stale session id is dropped, a fresh `session/new` starts, and the turn still answers coherently because the host re-materializes the per-channel `context.json` and re-injects the same bootstrap context on **every** turn regardless. What is lost in that case is only the CLI-native transcript. The situations that actually cause it:
|
|
521
508
|
|
|
522
509
|
- **A different machine or a different `$HOME`.** The session store is local, so continuity never crosses hosts.
|
|
523
510
|
- **A stale Copilot session lock.** The Copilot CLI does not reliably unlink its `inuse.<pid>.lock` on an unclean exit ([github/copilot-cli#3255](https://github.com/github/copilot-cli/issues/3255), still open), so a force-killed process can leave a lock that makes the next restore believe the session is still in use. The idle teardown mitigates this: it gives the CLI a 5-second SIGTERM grace before any SIGKILL — twenty times the grace a fatal teardown uses — precisely so the CLI has time to unlink that lock. This is an internal default, not a knob.
|
|
524
511
|
- **A very large Copilot transcript.** Long-running channels can hit upstream resume bugs: slow or memory-hungry resume ([#4251](https://github.com/github/copilot-cli/issues/4251)), a session that becomes unloadable once `events.jsonl` outgrows the V8 maximum string length ([#4325](https://github.com/github/copilot-cli/issues/4325)), and a resume-triggered compaction hang ([#4138](https://github.com/github/copilot-cli/issues/4138)). A Copilot resume may also reset the model back to the default ([#4397](https://github.com/github/copilot-cli/issues/4397)).
|
|
525
|
-
- **Our own mapping rules, which are stricter than the CLI's.** We only reuse a persisted session id when the session's working directory *and* the set of directories exposed to it still match. So if the resolved channel
|
|
512
|
+
- **Our own mapping rules, which are stricter than the CLI's.** We only reuse a persisted session id when the session's working directory *and* the set of directories exposed to it still match. So if the resolved channel working folder path changes, or if the injected provider-visible directory set changes, the host deliberately drops the old mapping and starts a fresh session even though the CLI could still load it.
|
|
526
513
|
|
|
527
514
|
If you would rather keep the adapter process resident than take any of that, set `PROVIDER_IDLE_SHUTDOWN_MINUTES=0`, raise the window, or roll the behavior back entirely with `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown`.
|
|
528
515
|
|
|
@@ -17,6 +17,7 @@ export interface AgentsHostSupervisorDeps {
|
|
|
17
17
|
watchPath?: (path: string, onEvent: (event?: WatchEventInfo) => void) => WatchHandle;
|
|
18
18
|
logger?: LoggerLike;
|
|
19
19
|
debug?: boolean;
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
20
21
|
}
|
|
21
22
|
export declare class AgentsHostSupervisor {
|
|
22
23
|
private readonly hostConfigPath;
|
|
@@ -26,7 +26,9 @@ export class AgentsHostSupervisor {
|
|
|
26
26
|
constructor(hostConfigPath, deps = {}) {
|
|
27
27
|
this.hostConfigPath = resolve(hostConfigPath);
|
|
28
28
|
this.createHost = deps.createHost ?? ((config, runtimeOptions) => new AgentsHost(config, { runtimeOptions }));
|
|
29
|
-
this.loadSnapshot =
|
|
29
|
+
this.loadSnapshot =
|
|
30
|
+
deps.loadSnapshot ??
|
|
31
|
+
((configPath) => loadLocalConfigSnapshot(configPath, { platform: deps.platform }));
|
|
30
32
|
this.watchPath =
|
|
31
33
|
deps.watchPath ??
|
|
32
34
|
((path, onEvent) => watch(path, (eventType, filename) => {
|
package/dist/agents-host.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare class AgentsHost {
|
|
|
11
11
|
private readonly runtime;
|
|
12
12
|
private readonly provider;
|
|
13
13
|
private readonly borgee;
|
|
14
|
+
private readonly backgroundRuns;
|
|
14
15
|
private readonly logger;
|
|
15
16
|
private readonly contextInjectionEnabled;
|
|
16
17
|
private readonly collaborationEnabled;
|
|
@@ -33,6 +34,7 @@ export declare class AgentsHost {
|
|
|
33
34
|
private readonly compactionSnapshotWriteQueueByChannel;
|
|
34
35
|
private readonly missedCollaborationDiagnosticByChannel;
|
|
35
36
|
private readonly channelTurnQueues;
|
|
37
|
+
private readonly autonomousReplyKeys;
|
|
36
38
|
private readonly collaborationDrafts;
|
|
37
39
|
private readonly collaborationChannels;
|
|
38
40
|
private readonly participantDirectoryByUserId;
|
|
@@ -54,6 +56,7 @@ export declare class AgentsHost {
|
|
|
54
56
|
stop(): Promise<void>;
|
|
55
57
|
private trackActiveTurn;
|
|
56
58
|
private dispatchMessage;
|
|
59
|
+
private dispatchAutonomousReply;
|
|
57
60
|
private handleLegacyMessage;
|
|
58
61
|
private handleCollaborationMessage;
|
|
59
62
|
private detectProtocolKickoffCandidate;
|
|
@@ -144,10 +147,9 @@ export declare class AgentsHost {
|
|
|
144
147
|
* exists — including its ending, which a window opened before it was replaced
|
|
145
148
|
* would otherwise carry out on its behalf.
|
|
146
149
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* same answer for a turn that keeps its reply to itself.
|
|
150
|
+
* Silent internal turns do not open a reporter. Deferred turns still report
|
|
151
|
+
* because they represent user-visible work even when final delivery happens
|
|
152
|
+
* elsewhere.
|
|
151
153
|
*/
|
|
152
154
|
private beginTurnActivity;
|
|
153
155
|
private canAcceptTurnOutput;
|
package/dist/agents-host.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
4
4
|
import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
|
|
5
5
|
import { HostLogger, summarizeError } from './debug.js';
|
|
6
6
|
import { toReportedActivity } from './progress-to-activity.js';
|
|
7
|
+
import { BackgroundRunTracker } from './background-runs.js';
|
|
7
8
|
import { ATTENTION_FOLLOW_SEMANTICS_COMPATIBILITY_GATE, COLLABORATION_CAPABILITIES_DIAGNOSTICS_COMPATIBILITY_GATE, COLLABORATION_OUTCOME_MODEL_COMPATIBILITY_GATE, COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, TASK_THREAD_COLLABORATION_CONTRACT_COMPATIBILITY_GATE, TOKEN_BINDING_COMPATIBILITY_GATE, } from './compatibility-gates.js';
|
|
8
9
|
import { buildNormalizedAttentionSnapshot, parsePersistedAttentionSnapshot, } from './context/attention.js';
|
|
9
10
|
import { mergeCompactionSnapshot, parsePersistedCompactionSnapshot, } from './context/compaction.js';
|
|
@@ -149,15 +150,17 @@ class DefaultHostRuntime {
|
|
|
149
150
|
});
|
|
150
151
|
this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
|
|
151
152
|
}
|
|
152
|
-
async start(onMessage, onStopTurn) {
|
|
153
|
+
async start(onMessage, onStopTurn, onStopBackgroundRun) {
|
|
153
154
|
await this.ensureStateRoot(this.config.stateRootDir);
|
|
154
155
|
await this.gateway.start();
|
|
155
156
|
try {
|
|
156
157
|
this.controlPlane.onStopTurn?.(onStopTurn);
|
|
158
|
+
this.controlPlane.onStopBackgroundRun?.(onStopBackgroundRun);
|
|
157
159
|
await this.controlPlane.connect(onMessage);
|
|
158
160
|
}
|
|
159
161
|
catch (error) {
|
|
160
162
|
this.controlPlane.onStopTurn?.(undefined);
|
|
163
|
+
this.controlPlane.onStopBackgroundRun?.(undefined);
|
|
161
164
|
await this.gateway.stop().catch(() => { });
|
|
162
165
|
throw error;
|
|
163
166
|
}
|
|
@@ -166,6 +169,7 @@ class DefaultHostRuntime {
|
|
|
166
169
|
let thrown;
|
|
167
170
|
try {
|
|
168
171
|
this.controlPlane.onStopTurn?.(undefined);
|
|
172
|
+
this.controlPlane.onStopBackgroundRun?.(undefined);
|
|
169
173
|
await this.controlPlane.close();
|
|
170
174
|
}
|
|
171
175
|
catch (error) {
|
|
@@ -272,6 +276,7 @@ const AGENT_SESSION_TASK_PROPERTY_KEY = 'agent.session_id';
|
|
|
272
276
|
const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
|
|
273
277
|
const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
|
|
274
278
|
const COLLABORATION_SENDS_PER_TURN = 1;
|
|
279
|
+
const MAX_AUTONOMOUS_REPLY_DEDUPLICATION_KEYS = 256;
|
|
275
280
|
/**
|
|
276
281
|
* Minimal single-agent agents host: connects one local Claude/Copilot CLI
|
|
277
282
|
* to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
|
|
@@ -281,6 +286,7 @@ export class AgentsHost {
|
|
|
281
286
|
runtime;
|
|
282
287
|
provider;
|
|
283
288
|
borgee;
|
|
289
|
+
backgroundRuns;
|
|
284
290
|
logger;
|
|
285
291
|
contextInjectionEnabled;
|
|
286
292
|
collaborationEnabled;
|
|
@@ -303,6 +309,7 @@ export class AgentsHost {
|
|
|
303
309
|
compactionSnapshotWriteQueueByChannel = new Map();
|
|
304
310
|
missedCollaborationDiagnosticByChannel = new Map();
|
|
305
311
|
channelTurnQueues = new Map();
|
|
312
|
+
autonomousReplyKeys = new Map();
|
|
306
313
|
collaborationDrafts = new Map();
|
|
307
314
|
collaborationChannels = new Map();
|
|
308
315
|
participantDirectoryByUserId = new Map();
|
|
@@ -349,6 +356,16 @@ export class AgentsHost {
|
|
|
349
356
|
});
|
|
350
357
|
this.provider = this.runtime.provider;
|
|
351
358
|
this.borgee = this.runtime.controlPlane;
|
|
359
|
+
const backgroundRunCapabilities = this.provider.capabilities?.backgroundRuns;
|
|
360
|
+
this.backgroundRuns = new BackgroundRunTracker((channelId, activity) => {
|
|
361
|
+
this.borgee.reportActivity?.({ channelId, activity });
|
|
362
|
+
}, {
|
|
363
|
+
lifecycleSupported: backgroundRunCapabilities?.lifecycle === 'supported',
|
|
364
|
+
targetedCancellationSupported: backgroundRunCapabilities?.targetedCancellation === 'supported',
|
|
365
|
+
cancel: this.provider.cancelBackgroundRun
|
|
366
|
+
? (channelId, providerRunId) => (this.provider.cancelBackgroundRun(channelId, providerRunId))
|
|
367
|
+
: undefined,
|
|
368
|
+
});
|
|
352
369
|
}
|
|
353
370
|
async start() {
|
|
354
371
|
if (this.started)
|
|
@@ -364,6 +381,7 @@ export class AgentsHost {
|
|
|
364
381
|
const bufferedMessages = [];
|
|
365
382
|
let bufferingMessages = true;
|
|
366
383
|
try {
|
|
384
|
+
this.provider.onAutonomousReply?.((reply) => this.dispatchAutonomousReply(reply));
|
|
367
385
|
await this.runtime.start((message) => {
|
|
368
386
|
if (!this.started) {
|
|
369
387
|
return;
|
|
@@ -373,7 +391,7 @@ export class AgentsHost {
|
|
|
373
391
|
return;
|
|
374
392
|
}
|
|
375
393
|
this.dispatchMessage(message);
|
|
376
|
-
}, async (channelId) => this.stopTurn(channelId));
|
|
394
|
+
}, async (channelId) => this.stopTurn(channelId), async (channelId, runId) => this.backgroundRuns.stop(channelId, runId));
|
|
377
395
|
const agentId = await this.ensureSelfAgentId();
|
|
378
396
|
bufferingMessages = false;
|
|
379
397
|
while (bufferedMessages.length > 0) {
|
|
@@ -393,6 +411,7 @@ export class AgentsHost {
|
|
|
393
411
|
bufferedMessages.length = 0;
|
|
394
412
|
this.started = false;
|
|
395
413
|
this.controlPlaneClosed = true;
|
|
414
|
+
this.provider.onAutonomousReply?.(undefined);
|
|
396
415
|
await this.runtime.stop().catch(() => { });
|
|
397
416
|
throw error;
|
|
398
417
|
}
|
|
@@ -401,6 +420,7 @@ export class AgentsHost {
|
|
|
401
420
|
if (!this.started)
|
|
402
421
|
return;
|
|
403
422
|
this.started = false;
|
|
423
|
+
this.provider.onAutonomousReply?.(undefined);
|
|
404
424
|
this.logger.debug('stopping host', {
|
|
405
425
|
activeTurnCount: this.activeTurns.size,
|
|
406
426
|
provider: this.config.agent.provider,
|
|
@@ -420,6 +440,7 @@ export class AgentsHost {
|
|
|
420
440
|
this.activeCompactionByChannel.clear();
|
|
421
441
|
this.compactionSnapshotLoadedChannels.clear();
|
|
422
442
|
this.missedCollaborationDiagnosticByChannel.clear();
|
|
443
|
+
this.autonomousReplyKeys.clear();
|
|
423
444
|
this.recordedAgentSessionByTask.clear();
|
|
424
445
|
this.controlPlaneClosed = true;
|
|
425
446
|
const pendingCompactionSnapshotWrites = [...this.compactionSnapshotWriteQueueByChannel.values()];
|
|
@@ -449,6 +470,57 @@ export class AgentsHost {
|
|
|
449
470
|
: this.enqueueChannelTurn(message.channel_id, () => this.handleLegacyMessage(message));
|
|
450
471
|
this.trackActiveTurn(task);
|
|
451
472
|
}
|
|
473
|
+
dispatchAutonomousReply(reply) {
|
|
474
|
+
if (!this.started
|
|
475
|
+
|| this.controlPlaneClosed
|
|
476
|
+
|| !reply.channelId.trim()
|
|
477
|
+
|| !reply.eventId.trim()
|
|
478
|
+
|| !hasVisibleText(reply.text)) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const key = `${reply.channelId}\0${reply.eventId}`;
|
|
482
|
+
if (this.autonomousReplyKeys.has(key)) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.autonomousReplyKeys.set(key, true);
|
|
486
|
+
while (this.autonomousReplyKeys.size > MAX_AUTONOMOUS_REPLY_DEDUPLICATION_KEYS) {
|
|
487
|
+
const oldest = this.autonomousReplyKeys.keys().next().value;
|
|
488
|
+
if (typeof oldest !== 'string') {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
this.autonomousReplyKeys.delete(oldest);
|
|
492
|
+
}
|
|
493
|
+
const task = this.enqueueChannelTurn(reply.channelId, async () => {
|
|
494
|
+
if (!this.started || this.controlPlaneClosed) {
|
|
495
|
+
this.autonomousReplyKeys.delete(key);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
await this.borgee.postMessage({
|
|
500
|
+
channelId: reply.channelId,
|
|
501
|
+
body: reply.text,
|
|
502
|
+
});
|
|
503
|
+
this.logger.debug('posted provider autonomous reply', {
|
|
504
|
+
channelId: reply.channelId,
|
|
505
|
+
provider: this.config.agent.provider,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
this.autonomousReplyKeys.delete(key);
|
|
510
|
+
this.logger.debugError('failed to post provider autonomous reply', {
|
|
511
|
+
channelId: reply.channelId,
|
|
512
|
+
provider: this.config.agent.provider,
|
|
513
|
+
error,
|
|
514
|
+
});
|
|
515
|
+
this.logger.error('failed to post provider autonomous reply', {
|
|
516
|
+
channelId: reply.channelId,
|
|
517
|
+
provider: this.config.agent.provider,
|
|
518
|
+
error: summarizeError(error),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
this.trackActiveTurn(task);
|
|
523
|
+
}
|
|
452
524
|
async handleLegacyMessage(msg) {
|
|
453
525
|
const normalized = await this.normalizeMessage(msg);
|
|
454
526
|
if (!normalized) {
|
|
@@ -570,8 +642,20 @@ export class AgentsHost {
|
|
|
570
642
|
if (this.isHumanWakeFiltered(msg, authorKind, attentionSnapshot)) {
|
|
571
643
|
return;
|
|
572
644
|
}
|
|
645
|
+
let foregroundInterrupt;
|
|
573
646
|
if (authorKind !== 'agent' && channelState.activeTurn) {
|
|
574
647
|
this.supersedeActiveTurn(msg.channel_id, channelState.activeTurn);
|
|
648
|
+
foregroundInterrupt = (async () => {
|
|
649
|
+
try {
|
|
650
|
+
await this.provider.interruptTurn?.(msg.channel_id);
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
this.logger.error('failed to interrupt superseded provider turn; replacement remains queued', {
|
|
654
|
+
channelId: msg.channel_id,
|
|
655
|
+
error: summarizeError(error),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
})();
|
|
575
659
|
}
|
|
576
660
|
if (authorKind !== 'agent') {
|
|
577
661
|
channelState.recentTurn = undefined;
|
|
@@ -585,6 +669,7 @@ export class AgentsHost {
|
|
|
585
669
|
attentionSnapshot,
|
|
586
670
|
taskThreadContext,
|
|
587
671
|
});
|
|
672
|
+
await foregroundInterrupt;
|
|
588
673
|
await this.ensureCollaborationProcessor(msg.channel_id, channelState);
|
|
589
674
|
}
|
|
590
675
|
detectProtocolKickoffCandidate(anchorMessageId, content, selfAgentId) {
|
|
@@ -969,6 +1054,9 @@ export class AgentsHost {
|
|
|
969
1054
|
async ensureCollaborationProcessor(channelId, channelState) {
|
|
970
1055
|
if (channelState.processor) {
|
|
971
1056
|
await channelState.processor;
|
|
1057
|
+
if (channelState.queue.length > 0 && !channelState.processor) {
|
|
1058
|
+
await this.ensureCollaborationProcessor(channelId, channelState);
|
|
1059
|
+
}
|
|
972
1060
|
return;
|
|
973
1061
|
}
|
|
974
1062
|
const processor = this.processCollaborationQueue(channelId, channelState).finally(() => {
|
|
@@ -2230,7 +2318,10 @@ export class AgentsHost {
|
|
|
2230
2318
|
this.collaborationEnabled &&
|
|
2231
2319
|
turnExecutionId != null &&
|
|
2232
2320
|
activeTurn?.protocol === undefined;
|
|
2233
|
-
const
|
|
2321
|
+
const supportsProviderProgress = this.provider.capabilities?.progressUpdates?.support === 'supported';
|
|
2322
|
+
const supportsBackgroundRunLifecycle = this.provider.capabilities?.backgroundRuns?.lifecycle === 'supported';
|
|
2323
|
+
const reportsProviderActivity = !silentTurn && supportsProviderProgress;
|
|
2324
|
+
const observeProviderProgress = supportsProviderProgress || supportsBackgroundRunLifecycle;
|
|
2234
2325
|
const turnContext = {
|
|
2235
2326
|
eventType: msg.type,
|
|
2236
2327
|
channelId,
|
|
@@ -2257,7 +2348,9 @@ export class AgentsHost {
|
|
|
2257
2348
|
text: String(msg.content ?? msg.body ?? content),
|
|
2258
2349
|
attachments: msg.attachments,
|
|
2259
2350
|
});
|
|
2260
|
-
const activityReporter =
|
|
2351
|
+
const activityReporter = silentTurn
|
|
2352
|
+
? undefined
|
|
2353
|
+
: this.beginTurnActivity(channelId, activeTurn?.generation);
|
|
2261
2354
|
await this.ensureCompactionSnapshotLoaded(channelId);
|
|
2262
2355
|
const compactionSnapshot = this.compactionSnapshotByChannel.get(channelId);
|
|
2263
2356
|
try {
|
|
@@ -2304,20 +2397,25 @@ export class AgentsHost {
|
|
|
2304
2397
|
}, observeProviderProgress
|
|
2305
2398
|
? {
|
|
2306
2399
|
onProgress: (update) => {
|
|
2400
|
+
if (supportsBackgroundRunLifecycle) {
|
|
2401
|
+
this.backgroundRuns.observe(channelId, update);
|
|
2402
|
+
}
|
|
2307
2403
|
if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
|
|
2308
2404
|
return;
|
|
2309
2405
|
}
|
|
2310
|
-
if (update.type === 'compaction') {
|
|
2406
|
+
if (supportsProviderProgress && update.type === 'compaction') {
|
|
2311
2407
|
this.noteCompactionProgress(channelId, update.compaction, {
|
|
2312
2408
|
turnExecutionId: activeTurn?.turnExecutionId ?? undefined,
|
|
2313
2409
|
});
|
|
2314
2410
|
return;
|
|
2315
2411
|
}
|
|
2316
2412
|
const reportedActivity = toReportedActivity(update);
|
|
2317
|
-
if (reportedActivity) {
|
|
2318
|
-
activityReporter
|
|
2413
|
+
if (reportsProviderActivity && reportedActivity) {
|
|
2414
|
+
activityReporter?.progress(reportedActivity);
|
|
2319
2415
|
}
|
|
2320
|
-
if (
|
|
2416
|
+
if (!supportsProviderProgress
|
|
2417
|
+
|| update.type !== 'text'
|
|
2418
|
+
|| update.stream !== 'answer') {
|
|
2321
2419
|
return;
|
|
2322
2420
|
}
|
|
2323
2421
|
if (!publishesCollaborationDraft) {
|
|
@@ -2527,7 +2625,7 @@ export class AgentsHost {
|
|
|
2527
2625
|
}
|
|
2528
2626
|
finally {
|
|
2529
2627
|
stopTyping();
|
|
2530
|
-
activityReporter
|
|
2628
|
+
activityReporter?.end();
|
|
2531
2629
|
}
|
|
2532
2630
|
}
|
|
2533
2631
|
/**
|
|
@@ -2539,10 +2637,9 @@ export class AgentsHost {
|
|
|
2539
2637
|
* exists — including its ending, which a window opened before it was replaced
|
|
2540
2638
|
* would otherwise carry out on its behalf.
|
|
2541
2639
|
*
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
2545
|
-
* same answer for a turn that keeps its reply to itself.
|
|
2640
|
+
* Silent internal turns do not open a reporter. Deferred turns still report
|
|
2641
|
+
* because they represent user-visible work even when final delivery happens
|
|
2642
|
+
* elsewhere.
|
|
2546
2643
|
*/
|
|
2547
2644
|
beginTurnActivity(channelId, generation) {
|
|
2548
2645
|
const reporter = this.borgee.reportTurnActivity({
|
|
@@ -2710,8 +2807,11 @@ export class AgentsHost {
|
|
|
2710
2807
|
turnExecutionId: activeTurn.turnExecutionId,
|
|
2711
2808
|
});
|
|
2712
2809
|
}
|
|
2810
|
+
// Output acceptance checks `superseded`, so a stale foreground turn still
|
|
2811
|
+
// cannot stream, draft, or post a visible reply after the interrupt. The
|
|
2812
|
+
// id stays on the running turn until it unwinds so background children that
|
|
2813
|
+
// inherited it can finish their own gateway work cleanly.
|
|
2713
2814
|
activeTurn.superseded = true;
|
|
2714
|
-
activeTurn.turnExecutionId = null;
|
|
2715
2815
|
}
|
|
2716
2816
|
invalidateChannelTurn(channelId) {
|
|
2717
2817
|
const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
|
|
@@ -2721,7 +2821,9 @@ export class AgentsHost {
|
|
|
2721
2821
|
}
|
|
2722
2822
|
async stopTurn(channelId) {
|
|
2723
2823
|
const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
|
|
2724
|
-
const aborted = await this.provider.
|
|
2824
|
+
const aborted = await (this.provider.cancelSession?.(channelId)
|
|
2825
|
+
?? this.provider.cancelTurn?.(channelId)
|
|
2826
|
+
?? false);
|
|
2725
2827
|
if (!aborted) {
|
|
2726
2828
|
return { aborted: false };
|
|
2727
2829
|
}
|
|
@@ -2759,7 +2861,6 @@ export class AgentsHost {
|
|
|
2759
2861
|
resolveAuthorizedCollaborationTurn(channelState, turnExecutionId) {
|
|
2760
2862
|
const activeTurn = channelState?.activeTurn;
|
|
2761
2863
|
if (activeTurn &&
|
|
2762
|
-
!activeTurn.superseded &&
|
|
2763
2864
|
activeTurn.turnExecutionId != null &&
|
|
2764
2865
|
activeTurn.turnExecutionId === turnExecutionId) {
|
|
2765
2866
|
return activeTurn;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentActivity } from './plugin-sdk.js';
|
|
2
|
+
import type { ProviderProgressUpdate } from './types.js';
|
|
3
|
+
interface BackgroundRunTrackerOptions {
|
|
4
|
+
lifecycleSupported: boolean;
|
|
5
|
+
targetedCancellationSupported: boolean;
|
|
6
|
+
cancel?: (channelId: string, providerRunId: string) => Promise<boolean>;
|
|
7
|
+
}
|
|
8
|
+
export declare class BackgroundRunTracker {
|
|
9
|
+
private readonly report;
|
|
10
|
+
private readonly options;
|
|
11
|
+
private readonly now;
|
|
12
|
+
private readonly mintRunId;
|
|
13
|
+
private readonly runs;
|
|
14
|
+
private readonly runsById;
|
|
15
|
+
constructor(report: (channelId: string, activity: AgentActivity) => void, options: BackgroundRunTrackerOptions, now?: () => number, mintRunId?: (channelId: string, providerRunId: string, startedAt: number) => string);
|
|
16
|
+
observe(channelId: string, update: ProviderProgressUpdate): void;
|
|
17
|
+
stop(channelId: string, runId: string): Promise<{
|
|
18
|
+
outcome: 'stopped' | 'not_found' | 'unsupported';
|
|
19
|
+
reason?: string;
|
|
20
|
+
}>;
|
|
21
|
+
private publish;
|
|
22
|
+
}
|
|
23
|
+
export {};
|