mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): replace facet attributes with services
Make facets non-generic and resolve host-local dependencies through service bindings. Rename local assembly to createFacetHost and consolidate TUI host mechanics behind one service.
This commit is contained in:
parent
f0bfae2a96
commit
97f7472f4e
17 changed files with 308 additions and 204 deletions
|
|
@ -73,7 +73,7 @@ Temporary cross-generation communication is expected. The system does not attemp
|
|||
|
||||
## Dependency discovery and manifest validation
|
||||
|
||||
The service dependency graph is generated from facet setup, not reflected from erased TypeScript interfaces and not maintained as a parallel handwritten list. Every service token retains a stable runtime ID. Each host combines the common facet environment with the attributes for that facet kind. Setup runs synchronously as a declaration phase; service handles remain disconnected until the complete graph validates.
|
||||
The service dependency graph is generated from facet setup, not reflected from erased TypeScript interfaces and not maintained as a parallel handwritten list. Every service token retains a stable runtime ID. Each host binds its concrete local services before activating the loaded facets. Setup runs synchronously as a declaration phase; service handles remain disconnected until the complete graph validates.
|
||||
|
||||
During setup the host records:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Coding-Agent Application Hosts and Plugin Facets
|
||||
|
||||
> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `ReplicatedState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` now has an experimental transport-neutral implementation with ordered non-replayed delivery, while the remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application facet environments and attributes, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. Plugin reload semantics are specified separately in [`plugin-reloading.md`](plugin-reloading.md). Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package.
|
||||
> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `ReplicatedState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` now has an experimental transport-neutral implementation with ordered non-replayed delivery, while the remaining documented built-in service contracts retain explicit `ServiceSliceNotImplemented` members. The application facet environment, host service bindings, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. Plugin reload semantics are specified separately in [`plugin-reloading.md`](plugin-reloading.md). Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package.
|
||||
|
||||
This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `Branch`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, `telemetry.md` for context propagation and cancellation semantics, and `plugin-reloading.md` for manifest-generation replacement and durable reconstruction.
|
||||
|
||||
|
|
@ -44,22 +44,22 @@ A coding-agent plugin is a feature bundle that may provide a facet for any host:
|
|||
```ts
|
||||
interface CodingAgentPlugin {
|
||||
readonly id: string;
|
||||
readonly server?: PluginFacet<ServerFacetAttributes>;
|
||||
readonly session?: PluginFacet<SessionFacetAttributes>;
|
||||
readonly server?: PluginFacet;
|
||||
readonly session?: PluginFacet;
|
||||
readonly tui?: {
|
||||
readonly server?: PluginFacet<TuiFacetAttributes>;
|
||||
readonly session?: PluginFacet<TuiFacetAttributes>;
|
||||
readonly server?: PluginFacet;
|
||||
readonly session?: PluginFacet;
|
||||
};
|
||||
readonly web?: {
|
||||
readonly server?: PluginFacet<WebFacetAttributes>;
|
||||
readonly session?: PluginFacet<WebFacetAttributes>;
|
||||
readonly server?: PluginFacet;
|
||||
readonly session?: PluginFacet;
|
||||
};
|
||||
}
|
||||
|
||||
type PluginFacet<TAttributes extends object> = (env: FacetEnvironment & TAttributes) => void;
|
||||
type PluginFacet = (env: FacetEnvironment) => void;
|
||||
```
|
||||
|
||||
`PluginFacet` is the only plugin-facing shape the generic kernel needs. The host placement is the facet kind: it selects the lifetime, service source, and application-specific attributes added to the common environment. Facets are optional: a `models.json` plugin has only a Session facet, a terminal theme only presentation facets, and the Session directory has a server provider facet plus a server TUI facet. Setup is a synchronous declaration phase; asynchronous initialization belongs in `onActivate`. Examples use an illustrative `definePlugin()` helper returning a `CodingAgentPlugin` — the loaded, in-process shape.
|
||||
`PluginFacet` is the only plugin-facing shape the generic kernel needs. The host placement is the facet kind: it selects the lifetime and the local or remote service sources available through `env.use()`. Facets are optional: a `models.json` plugin has only a Session facet, a terminal theme only presentation facets, and the Session directory has a server provider facet plus a server TUI facet. Setup is a synchronous declaration phase; asynchronous initialization belongs in `onActivate`. Examples use an illustrative `definePlugin()` helper returning a `CodingAgentPlugin` — the loaded, in-process shape.
|
||||
|
||||
A package keeps shared wire contracts separate from host dependencies:
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ type ConnectionState =
|
|||
| { status: "disconnected"; since: string; reason: string; retryAt: string | null };
|
||||
```
|
||||
|
||||
After transport setup, each host asks the kernel to create one environment per selected facet, adds its host-specific attributes, and invokes the facet:
|
||||
After transport setup, each host gives the kernel its loaded facets and binds its concrete local services. The kernel invokes each facet against the resulting service graph:
|
||||
|
||||
```ts
|
||||
interface FacetLifecycle {
|
||||
|
|
@ -323,11 +323,12 @@ export const modelSelection = definePlugin({
|
|||
tui: {
|
||||
session(env) {
|
||||
const models = env.use(Models);
|
||||
const tui = env.use(Tui);
|
||||
|
||||
env.commands.register("models.select", async (context) => {
|
||||
tui.commands.register("models.select", async (context) => {
|
||||
const current = models.state.value;
|
||||
if (current === undefined) return;
|
||||
const selected = await env.ui.select(
|
||||
const selected = await tui.select(
|
||||
"Models",
|
||||
current.catalog.availableModels.map((model) => ({
|
||||
label: model.name,
|
||||
|
|
@ -337,7 +338,7 @@ export const modelSelection = definePlugin({
|
|||
);
|
||||
if (selected !== undefined) await models.select(selected, context);
|
||||
});
|
||||
env.commands.register("models.cycle-thinking", (context) => models.cycleThinking(context));
|
||||
tui.commands.register("models.cycle-thinking", (context) => models.cycleThinking(context));
|
||||
env.own(models.state.subscribe((next) => renderModelSelector(next)));
|
||||
},
|
||||
},
|
||||
|
|
@ -396,18 +397,21 @@ interface AgentPluginScope {
|
|||
readonly events: ScopedEvents;
|
||||
}
|
||||
|
||||
interface SessionFacetAttributes {
|
||||
readonly agent: AgentPluginScope; // scoped local Session authority
|
||||
readonly providers: ProviderContributionRegistry;
|
||||
readonly tools: ToolContributionRegistry;
|
||||
onClientAttach(callback: (clientId: string) => void): void;
|
||||
onClientDetach(callback: (clientId: string) => void): void;
|
||||
const Agent = defineService<AgentPluginScope>("pi.local.agent");
|
||||
const Providers = defineService<ProviderContributionRegistry>("pi.local.providers");
|
||||
const Tools = defineService<ToolContributionRegistry>("pi.local.tools");
|
||||
|
||||
interface ClientLifecycle {
|
||||
onAttach(callback: (clientId: string) => void): void;
|
||||
onDetach(callback: (clientId: string) => void): void;
|
||||
}
|
||||
|
||||
const Clients = defineService<ClientLifecycle>("pi.local.clients");
|
||||
```
|
||||
|
||||
"Local" and "unrestricted" are separate decisions. The scope narrows authority for lifecycle and composition — hooks and event subscriptions registered through it are automatically owned by the plugin and disposed with it. `AgentLanePluginView` exposes Branch methods directly alongside agent operations. `ScopedSessionData` exposes purpose-bounded global value/list/name/label/query operations. The host keeps the unrestricted concrete instances and reserves: `AgentHarness.close()` and `Session.close()`; raw `Session.mutate()`, `beginMutation()`, and `SessionMutator` (unless a narrowly trusted durability plugin explicitly owns them); `idGenerator` and backend/storage objects; Branch creation; whole-registry setters such as `setTools()`; unscoped hook/event registration; transport exposure and remote-reference registration. This is a composition and lifecycle boundary, not a security sandbox: session facets are trusted code in the authoritative process. The manifest may explicitly grant broader local capability, but built-ins receive no implicit bypass.
|
||||
|
||||
**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials — not even as proxies. It receives only what a session or server facet deliberately exposes: semantic service proxies and instances, replicated state, and semantic events.
|
||||
**Presentation facets hold none of this.** A TUI or web facet never receives the raw Harness, Session, tree, tool registry, hooks, or credentials. It uses host-local presentation services plus the semantic services, replicated state, and events deliberately exposed by a session or server facet.
|
||||
|
||||
```ts
|
||||
interface RemoteServiceInstance<T> {
|
||||
|
|
@ -441,34 +445,30 @@ interface TuiModal {
|
|||
close(): void;
|
||||
}
|
||||
|
||||
interface TuiFacilities {
|
||||
interface TuiHost {
|
||||
readonly connection: ReplicatedState<ConnectionState>;
|
||||
readonly attachment: ReplicatedState<AttachmentState>;
|
||||
readonly commands: CommandContributions;
|
||||
readonly keybindings: KeybindingContributions;
|
||||
readonly toolRenderers: ToolRendererContributions;
|
||||
readonly slots: SlotContributions;
|
||||
acquireModal(signal: AbortSignal): Promise<TuiModal>;
|
||||
select<T>(title: string, items: SelectItem<T>[], options: { signal: AbortSignal }): Promise<T | undefined>;
|
||||
// overlays and focus facilities
|
||||
}
|
||||
|
||||
interface TuiFacetAttributes {
|
||||
readonly connection: ReplicatedState<ConnectionState>;
|
||||
readonly attachment: ReplicatedState<AttachmentState>;
|
||||
readonly ui: TuiFacilities;
|
||||
readonly commands: CommandContributions;
|
||||
readonly keybindings: KeybindingContributions;
|
||||
readonly toolRenderers: ToolRendererContributions;
|
||||
readonly slots: SlotContributions;
|
||||
}
|
||||
const Tui = defineService<TuiHost>("pi.local.tui");
|
||||
```
|
||||
|
||||
`acquireModal()` waits in one presentation-owned queue and holds the modal slot across a multi-step interaction. Its signal removes a queued request or dismisses an active one, and `close()` is idempotent. `select()` is the one-step acquire/select/close convenience. Both return selected values directly, so feature code never recovers identity from a display label.
|
||||
|
||||
A server TUI facet and a Session TUI facet receive the same TUI attributes but run in different facet generations. The server generation binds `env.use(SessionDirectory)` to the connected server. The Session generation binds `env.use(Models)` to the selected Session. While detached, Session calls fail with `not_attached` and replicated state has no value. Connection and attachment health remain direct host attributes because they describe presentation control state, not services.
|
||||
|
||||
A future web host adds browser attributes—routes, views, and DOM dialogs—to the same common environment. Its server and Session facets still use unqualified service operations. The server attributes are defined in the [server section](#the-server-directory-management-and-routing).
|
||||
A server TUI facet and a Session TUI facet can use the same host-local TUI services but run in different facet generations. The server generation binds `env.use(SessionDirectory)` to the connected server. The Session generation binds `env.use(Models)` to the selected Session. While detached, Session calls fail with `not_attached` and replicated state has no value. Connection and attachment health are host-local services because they describe presentation control state. A future web host similarly binds local services for routes, views, and DOM dialogs. Its server and Session facets still use unqualified service operations.
|
||||
|
||||
Concretely, the minimum chat slice exposes `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)`, and implements it with direct local lane capabilities. The experimental `Chat` contract also predeclares the later steer, follow-up, next-run, queue cancellation, resume, compaction, and navigation presentation operations; those members throw `ServiceSliceNotImplemented` until their Harness adapter slices land:
|
||||
|
||||
```ts
|
||||
session(env) {
|
||||
const lane = env.agent.main;
|
||||
const lane = env.use(Agent).main;
|
||||
env.provide(Chat, {
|
||||
async prompt(request, context) {
|
||||
return toPromptResponse(await lane.prompt(request.message, context));
|
||||
|
|
@ -631,7 +631,7 @@ The server host does two jobs. It **owns server-wide services**—listing, creat
|
|||
|
||||
A server facet is shared by every session and presentation connected to the server. It should be used only for inherently server-wide features. Per-session feature data belongs in session facets.
|
||||
|
||||
### Server facet attributes
|
||||
### Server host services
|
||||
|
||||
```ts
|
||||
interface FleetPluginScope {
|
||||
|
|
@ -639,9 +639,7 @@ interface FleetPluginScope {
|
|||
readonly attachments: AttachmentsView; // bind/unbind a client's selected session
|
||||
}
|
||||
|
||||
interface ServerFacetAttributes {
|
||||
readonly fleet: FleetPluginScope;
|
||||
}
|
||||
const Fleet = defineService<FleetPluginScope>("pi.local.fleet");
|
||||
```
|
||||
|
||||
The raw `SessionRepo`, storage handles, unrestricted process-kill authority, routing map, and routing machinery stay with the server application:
|
||||
|
|
@ -705,8 +703,8 @@ export const SessionManagement = defineService<SessionManagement>("pi.session-ma
|
|||
|
||||
```ts
|
||||
// server.ts
|
||||
export function sessionDirectoryServerFacet(env: FacetEnvironment & ServerFacetAttributes) {
|
||||
const { managed, attachments } = env.fleet;
|
||||
export function sessionDirectoryServerFacet(env: FacetEnvironment) {
|
||||
const { managed, attachments } = env.use(Fleet);
|
||||
const state = env.remoteState({ revision: 0, sessions: [] as SessionRecordSummary[] });
|
||||
const events = env.remoteEvents<SessionDirectoryEvent>();
|
||||
|
||||
|
|
@ -759,15 +757,16 @@ Every call is authorized against the client identity that transport policy insta
|
|||
|
||||
```ts
|
||||
// tui.ts
|
||||
export function sessionPickerTuiFacet(env: FacetEnvironment & TuiFacetAttributes) {
|
||||
export function sessionPickerTuiFacet(env: FacetEnvironment) {
|
||||
const directory = env.use(SessionDirectory);
|
||||
const management = env.use(SessionManagement);
|
||||
const tui = env.use(Tui);
|
||||
|
||||
env.commands.register("sessions.switch", async (context) => {
|
||||
tui.commands.register("sessions.switch", async (context) => {
|
||||
const current = directory.state.value;
|
||||
const attachment = env.attachment.value;
|
||||
const attachment = tui.attachment.value;
|
||||
if (current === undefined || attachment === undefined) return;
|
||||
const selected = await env.ui.select(
|
||||
const selected = await tui.select(
|
||||
"Sessions",
|
||||
current.sessions.map((session) => ({
|
||||
label: pickerLabel(session, attachment),
|
||||
|
|
@ -871,10 +870,11 @@ function questionResult(request: QuestionRequest, answer: string | null, wasCust
|
|||
|
||||
```ts
|
||||
// session.ts
|
||||
export function questionSessionFacet(env: FacetEnvironment & SessionFacetAttributes) {
|
||||
export function questionSessionFacet(env: FacetEnvironment) {
|
||||
const dialogs = env.provideMany(QuestionDialogs);
|
||||
const tools = env.use(Tools);
|
||||
|
||||
env.tools.add((draft) => {
|
||||
tools.add((draft) => {
|
||||
draft.set("question", {
|
||||
label: "Question",
|
||||
description: "Ask users a question and wait for an answer.",
|
||||
|
|
@ -931,13 +931,14 @@ type QuestionChoice =
|
|||
| { outcome: "selected"; index: number }
|
||||
| { outcome: "custom" };
|
||||
|
||||
export function questionTuiFacet(env: FacetEnvironment & TuiFacetAttributes) {
|
||||
export function questionTuiFacet(env: FacetEnvironment) {
|
||||
const tui = env.use(Tui);
|
||||
env.own(
|
||||
env.observe(QuestionDialogs, async (dialog, context) => {
|
||||
const request = dialog.service.request.value;
|
||||
if (request === undefined) throw new Error("Question dialog was observed before hydration");
|
||||
|
||||
const modal = await bindings.ui.acquireModal(context.abortSignal);
|
||||
const modal = await tui.acquireModal(context.abortSignal);
|
||||
try {
|
||||
const choice = await modal.select<QuestionChoice>(
|
||||
request.question,
|
||||
|
|
@ -968,7 +969,7 @@ export function questionTuiFacet(env: FacetEnvironment & TuiFacetAttributes) {
|
|||
}),
|
||||
);
|
||||
|
||||
env.toolRenderers.add<QuestionDetails>("question", questionRenderer);
|
||||
tui.toolRenderers.add<QuestionDetails>("question", questionRenderer);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1021,7 +1022,7 @@ The generic kernel knows none of these domain concepts; each app host knows only
|
|||
|
||||
Before this becomes normative:
|
||||
|
||||
- the exact minimal kernel contract (environment shape, phase ordering, failure policy), the built-in manifest, and the concrete server/Session/TUI facet attributes;
|
||||
- the exact minimal kernel contract (environment shape, phase ordering, failure policy), the built-in manifest, and the concrete server/Session/TUI host services;
|
||||
- the logical-manifest format, per-host entry-point resolution, and cross-process version pinning;
|
||||
- whether directory state is projected per client (workspace-scoped snapshots) or one presentation-safe value plus method authorization;
|
||||
- multiple selected Sessions per presentation connection (a multi-pane web UI) — deferred; it changes how Session facet generations select service sources;
|
||||
|
|
@ -1221,7 +1222,7 @@ function toDiffReviewActivity(record: DiffReviewRecord): DiffReviewActivity {
|
|||
};
|
||||
}
|
||||
|
||||
export function diffReviewSessionFacet(bindings: FacetEnvironment & SessionFacetAttributes) {
|
||||
export function diffReviewSessionFacet(bindings: FacetEnvironment) {
|
||||
const reviews = bindings.provideMany(DiffReviews);
|
||||
const diffs = bindings.use(DiffSource);
|
||||
const records = bindings.use(DiffReviewRecords);
|
||||
|
|
@ -1323,16 +1324,18 @@ function observeDiffReviews(
|
|||
}
|
||||
|
||||
// tui.ts
|
||||
export function diffReviewTuiFacet(env: FacetEnvironment & TuiFacetAttributes) {
|
||||
export function diffReviewTuiFacet(env: FacetEnvironment) {
|
||||
const manager = env.use(DiffReviewManager);
|
||||
env.commands.register("diff.review", (context) => manager.createReview(context));
|
||||
observeDiffReviews(env, (document) => createTuiDiffReviewPanel(env.slots, document));
|
||||
const tui = env.use(Tui);
|
||||
tui.commands.register("diff.review", (context) => manager.createReview(context));
|
||||
observeDiffReviews(env, (document) => createTuiDiffReviewPanel(tui.slots, document));
|
||||
}
|
||||
|
||||
// web.ts
|
||||
export function diffReviewWebFacet(env: FacetEnvironment & WebFacetAttributes) {
|
||||
export function diffReviewWebFacet(env: FacetEnvironment) {
|
||||
const manager = env.use(DiffReviewManager);
|
||||
observeDiffReviews(env, (document) => createWebDiffReviewPanel(env.views, document));
|
||||
const views = env.use(WebViews);
|
||||
observeDiffReviews(env, (document) => createWebDiffReviewPanel(views, document));
|
||||
// The web plugin's "Review diff" button calls manager.createReview(context).
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -187,11 +187,12 @@ A server presentation facet renders and selects Sessions:
|
|||
setup(env) {
|
||||
const directory = env.use(SessionDirectory);
|
||||
const management = env.use(SessionManagement);
|
||||
const tui = env.use(Tui);
|
||||
|
||||
env.commands.register("sessions.switch", async (operation) => {
|
||||
tui.commands.register("sessions.switch", async (operation) => {
|
||||
const snapshot = directory.state.value;
|
||||
if (snapshot === undefined) return;
|
||||
const sessionId = await env.ui.select(
|
||||
const sessionId = await tui.select(
|
||||
"Sessions",
|
||||
snapshot.sessions.map((session) => ({ label: session.title, value: session.sessionId })),
|
||||
{ signal: operation.abortSignal },
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export {
|
|||
type RemoteEventType,
|
||||
type RemoteJson,
|
||||
type RemoteServiceConnection,
|
||||
type RemoteServiceContract,
|
||||
type RemoteServiceInstance,
|
||||
type RemoteServiceNamespaceApi,
|
||||
type RemoteServiceNamespaceOptions,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
cloneJson,
|
||||
isJsonValue,
|
||||
type RemoteServiceConnection,
|
||||
type RemoteServiceContract,
|
||||
type Service,
|
||||
type ServiceCall,
|
||||
type ServiceInstanceAddress,
|
||||
|
|
@ -80,7 +81,7 @@ export class RemoteServiceProvider {
|
|||
this.#allowlist = new Set(ids);
|
||||
}
|
||||
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<T>): void {
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(service.id);
|
||||
const registration = this.#registration(service.id, "singleton", true);
|
||||
|
|
@ -101,7 +102,7 @@ export class RemoteServiceProvider {
|
|||
return registration.singleton.implementation as T;
|
||||
}
|
||||
|
||||
spawn<T>(service: Service<T>, key: string, implementation: NoInfer<T>): () => void {
|
||||
spawn<T>(service: Service<T>, key: string, implementation: NoInfer<RemoteServiceContract<T>>): () => void {
|
||||
this.#assertActive();
|
||||
this.#assertAllowed(service.id);
|
||||
if (key.length === 0) throw new TypeError("Remote service instance key must not be empty");
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ type InvalidRemoteMemberNames<T> = {
|
|||
[TKey in keyof T]-?: InvalidRemoteMember<T[TKey]> extends never ? never : TKey;
|
||||
}[keyof T];
|
||||
|
||||
type CheckedRemoteContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
|
||||
export type RemoteServiceContract<T> = InvalidRemoteMemberNames<T> extends never ? T : never;
|
||||
|
||||
/** Stable identity for one shared TypeScript service contract. */
|
||||
export interface Service<T> {
|
||||
|
|
@ -93,7 +93,7 @@ export interface Service<T> {
|
|||
|
||||
export type ServiceType<TService> = TService extends Service<infer T> ? T : never;
|
||||
|
||||
export function defineService<T>(id: string): Service<CheckedRemoteContract<T>> {
|
||||
export function defineService<T>(id: string): Service<T> {
|
||||
if (id.length === 0) throw new TypeError("Remote service ID must not be empty");
|
||||
if (id.startsWith("$pi.")) throw new TypeError("Remote service IDs beginning with $pi. are reserved");
|
||||
return Object.freeze({ id });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BACKGROUND_CONTEXT, type Context, type ReplicatedState } from "@earendil-works/pi-agent-core";
|
||||
import { BACKGROUND_CONTEXT, type Context, defineService, type ReplicatedState } from "@earendil-works/pi-agent-core";
|
||||
import type { SessionSummary } from "@earendil-works/pi-protocol";
|
||||
import {
|
||||
type Component,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import chalk from "chalk";
|
||||
import type { ClientCommand } from "../cli/experimental/commands/client.ts";
|
||||
import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts";
|
||||
import { activateRemoteFacets, defineFacet, type FacetGeneration } from "./facets.ts";
|
||||
import { activateRemoteFacets, bindService, defineFacet, type RemoteFacetActivation } from "./facets.ts";
|
||||
import type { ServerServices, SessionAttachmentState, SessionServices } from "./services/connection.ts";
|
||||
import { Models, type Models as ModelsService } from "./services/models.ts";
|
||||
import {
|
||||
|
|
@ -39,21 +39,17 @@ interface ModelSelectionFeature {
|
|||
readonly models: ModelsService;
|
||||
}
|
||||
|
||||
interface SessionPickerFacetAttributes {
|
||||
readonly serverId: string;
|
||||
interface Tui {
|
||||
readonly attachment: ReplicatedState<SessionAttachmentState>;
|
||||
whenAttached(sessionId: string, context: Context): Promise<void>;
|
||||
whenDetached(context: Context): Promise<void>;
|
||||
register(feature: SessionPickerFeature): () => void;
|
||||
registerSessionPicker(feature: Omit<SessionPickerFeature, "serverId">): () => void;
|
||||
registerModelSelection(feature: Omit<ModelSelectionFeature, "serverId">): () => void;
|
||||
refresh(): void;
|
||||
setStatus(status: string): void;
|
||||
}
|
||||
|
||||
interface ModelSelectionFacetAttributes {
|
||||
readonly serverId: string;
|
||||
register(feature: ModelSelectionFeature): () => void;
|
||||
refresh(): void;
|
||||
}
|
||||
const Tui = defineService<Tui>("pi.local.tui");
|
||||
|
||||
type ClientTuiAction =
|
||||
| { readonly kind: "attach"; readonly feature: SessionPickerFeature; readonly sessionId: string }
|
||||
|
|
@ -70,46 +66,48 @@ const selectTheme = {
|
|||
noMatch: (text: string) => chalk.yellow(text),
|
||||
};
|
||||
|
||||
export const sessionPickerTuiFacet = defineFacet<SessionPickerFacetAttributes>({
|
||||
export const sessionPickerTuiFacet = defineFacet({
|
||||
id: "@pi/session-picker",
|
||||
setup(env) {
|
||||
const tui = env.use(Tui);
|
||||
const directory = env.use(SessionDirectory);
|
||||
const remoteManagement = env.use(SessionManagement);
|
||||
const management: SessionPickerFeature["management"] = {
|
||||
create: (options, context) => remoteManagement.create(options, context),
|
||||
async attach(sessionId, context) {
|
||||
await remoteManagement.attach(sessionId, context);
|
||||
await env.whenAttached(sessionId, context);
|
||||
await tui.whenAttached(sessionId, context);
|
||||
},
|
||||
async detach(context) {
|
||||
await remoteManagement.detach(context);
|
||||
await env.whenDetached(context);
|
||||
await tui.whenDetached(context);
|
||||
},
|
||||
};
|
||||
env.onActivate(() => {
|
||||
env.own(env.register({ serverId: env.serverId, directory, management, attachment: env.attachment }));
|
||||
env.own(directory.state.subscribe(env.refresh));
|
||||
env.own(tui.registerSessionPicker({ directory, management, attachment: tui.attachment }));
|
||||
env.own(directory.state.subscribe(tui.refresh));
|
||||
env.own(
|
||||
directory.events.subscribe((event) => {
|
||||
env.setStatus(
|
||||
tui.setStatus(
|
||||
event.type === "deleted"
|
||||
? `Session removed: ${event.sessionId}`
|
||||
: `Session ${event.type}: ${event.session.sessionId}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
env.own(env.attachment.subscribe(env.refresh));
|
||||
env.own(tui.attachment.subscribe(tui.refresh));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const modelSelectionTuiFacet = defineFacet<ModelSelectionFacetAttributes>({
|
||||
export const modelSelectionTuiFacet = defineFacet({
|
||||
id: "@pi/model-selection",
|
||||
setup(env) {
|
||||
const tui = env.use(Tui);
|
||||
const models = env.use(Models);
|
||||
env.onActivate(() => {
|
||||
env.own(env.register({ serverId: env.serverId, models }));
|
||||
env.own(models.state.subscribe(env.refresh));
|
||||
env.own(tui.registerModelSelection({ models }));
|
||||
env.own(models.state.subscribe(tui.refresh));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -119,7 +117,7 @@ export class ExperimentalClientTui implements Component {
|
|||
readonly #requestRender: () => void;
|
||||
readonly #finish: () => void;
|
||||
readonly #container = new Container();
|
||||
readonly #facetGenerations: FacetGeneration[] = [];
|
||||
readonly #remoteFacetActivations: RemoteFacetActivation[] = [];
|
||||
readonly #sessionPickers = new Map<string, SessionPickerFeature>();
|
||||
readonly #modelSelections = new Map<string, ModelSelectionFeature>();
|
||||
readonly #actions = new Map<string, ClientTuiAction>();
|
||||
|
|
@ -171,41 +169,42 @@ export class ExperimentalClientTui implements Component {
|
|||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= disposeFacetGenerations(this.#facetGenerations.splice(0).reverse());
|
||||
this.#closePromise ??= disposeRemoteFacetActivations(this.#remoteFacetActivations.splice(0).reverse());
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #start(servers: readonly ClientTuiServer[]): Promise<void> {
|
||||
for (const server of servers) {
|
||||
const serverGeneration = await activateRemoteFacets<SessionPickerFacetAttributes>({
|
||||
facets: [sessionPickerTuiFacet],
|
||||
attributes: {
|
||||
serverId: server.serverId,
|
||||
const bindings = [
|
||||
bindService(Tui, {
|
||||
attachment: server.session.attachment,
|
||||
whenAttached: (sessionId, context) => server.session.whenAttached(sessionId, context),
|
||||
whenDetached: (context) => server.session.whenDetached(context),
|
||||
register: (feature) => this.#register(this.#sessionPickers, "Session picker", feature),
|
||||
registerSessionPicker: (feature) =>
|
||||
this.#register(this.#sessionPickers, "Session picker", { ...feature, serverId: server.serverId }),
|
||||
registerModelSelection: (feature) =>
|
||||
this.#register(this.#modelSelections, "Model selection", { ...feature, serverId: server.serverId }),
|
||||
refresh: () => this.#rebuild(),
|
||||
setStatus: (status) => {
|
||||
this.#status = status;
|
||||
this.#rebuild();
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const serverActivation = await activateRemoteFacets({
|
||||
facets: [sessionPickerTuiFacet],
|
||||
bindings,
|
||||
source: server.server,
|
||||
connect: () => server.server.activate(BACKGROUND_CONTEXT),
|
||||
});
|
||||
this.#facetGenerations.push(serverGeneration);
|
||||
const sessionGeneration = await activateRemoteFacets<ModelSelectionFacetAttributes>({
|
||||
this.#remoteFacetActivations.push(serverActivation);
|
||||
const sessionActivation = await activateRemoteFacets({
|
||||
facets: [modelSelectionTuiFacet],
|
||||
attributes: {
|
||||
serverId: server.serverId,
|
||||
register: (feature) => this.#register(this.#modelSelections, "Model selection", feature),
|
||||
refresh: () => this.#rebuild(),
|
||||
},
|
||||
bindings,
|
||||
source: server.session,
|
||||
connect: () => server.session.activate(BACKGROUND_CONTEXT),
|
||||
});
|
||||
this.#facetGenerations.push(sessionGeneration);
|
||||
this.#remoteFacetActivations.push(sessionActivation);
|
||||
}
|
||||
this.#rebuild();
|
||||
}
|
||||
|
|
@ -386,8 +385,8 @@ export async function runClientTui(command: ClientCommand, options: OpenClientRu
|
|||
}
|
||||
}
|
||||
|
||||
async function disposeFacetGenerations(generations: readonly FacetGeneration[]): Promise<void> {
|
||||
const results = await Promise.allSettled(generations.map((generation) => generation.dispose()));
|
||||
async function disposeRemoteFacetActivations(activations: readonly RemoteFacetActivation[]): Promise<void> {
|
||||
const results = await Promise.allSettled(activations.map((activation) => activation.dispose()));
|
||||
const errors = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to dispose experimental TUI facets");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type MutableRemoteEvents,
|
||||
type MutableReplicatedState,
|
||||
type RemoteServiceConnection,
|
||||
type RemoteServiceContract,
|
||||
type RemoteServiceInstance,
|
||||
RemoteServiceNamespace,
|
||||
type RemoteServiceNamespaceApi,
|
||||
|
|
@ -16,7 +17,16 @@ import {
|
|||
} from "@earendil-works/pi-agent-core";
|
||||
|
||||
export interface ServiceInstances<T> {
|
||||
add(key: string, implementation: T): () => void;
|
||||
add(key: string, implementation: RemoteServiceContract<T>): () => void;
|
||||
}
|
||||
|
||||
export interface FacetServiceBinding {
|
||||
readonly service: { readonly id: string };
|
||||
readonly implementation: unknown;
|
||||
}
|
||||
|
||||
export function bindService<T>(service: Service<T>, implementation: NoInfer<T>): FacetServiceBinding {
|
||||
return Object.freeze({ service, implementation });
|
||||
}
|
||||
|
||||
interface FacetServiceReference {
|
||||
|
|
@ -36,7 +46,7 @@ export interface FacetEnvironment {
|
|||
service: Service<T>,
|
||||
handler: (instance: RemoteServiceInstance<T>, context: Context) => void | Promise<void>,
|
||||
): () => void;
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<T>): void;
|
||||
provide<T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void;
|
||||
provideMany<T>(service: Service<T>): ServiceInstances<T>;
|
||||
remoteState<T>(initial: T): MutableReplicatedState<T>;
|
||||
remoteEvents<T>(): MutableRemoteEvents<T>;
|
||||
|
|
@ -45,12 +55,12 @@ export interface FacetEnvironment {
|
|||
onDeactivate(callback: () => void | Promise<void>): void;
|
||||
}
|
||||
|
||||
export interface Facet<TAttributes extends object = object> {
|
||||
export interface Facet {
|
||||
readonly id: string;
|
||||
setup(env: FacetEnvironment & TAttributes): void;
|
||||
setup(env: FacetEnvironment): void;
|
||||
}
|
||||
|
||||
export function defineFacet<TAttributes extends object = object>(facet: Facet<TAttributes>): Facet<TAttributes> {
|
||||
export function defineFacet(facet: Facet): Facet {
|
||||
return facet;
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +250,57 @@ class LocalServiceHandles {
|
|||
}
|
||||
}
|
||||
|
||||
class FacetServiceBindings {
|
||||
readonly #assertAccess: () => void;
|
||||
readonly #implementations = new Map<string, object>();
|
||||
readonly #handles = new Map<string, object>();
|
||||
|
||||
constructor(bindings: readonly FacetServiceBinding[], assertAccess: () => void) {
|
||||
this.#assertAccess = assertAccess;
|
||||
for (const binding of bindings) {
|
||||
if (this.#implementations.has(binding.service.id)) {
|
||||
throw new Error(`Facet host service ${binding.service.id} is bound more than once`);
|
||||
}
|
||||
if (typeof binding.implementation !== "object" || binding.implementation === null) {
|
||||
throw new TypeError(`Facet host service ${binding.service.id} implementation must be an object`);
|
||||
}
|
||||
this.#implementations.set(binding.service.id, binding.implementation);
|
||||
}
|
||||
}
|
||||
|
||||
get serviceIds(): ReadonlySet<string> {
|
||||
return new Set(this.#implementations.keys());
|
||||
}
|
||||
|
||||
has(service: { readonly id: string }): boolean {
|
||||
return this.#implementations.has(service.id);
|
||||
}
|
||||
|
||||
use<T>(service: Service<T>): T {
|
||||
const implementation = this.#implementations.get(service.id);
|
||||
if (implementation === undefined) throw new Error(`Facet host service ${service.id} is not bound`);
|
||||
let handle = this.#handles.get(service.id);
|
||||
if (handle === undefined) {
|
||||
const methods = new Map<PropertyKey, unknown>();
|
||||
handle = new Proxy(implementation, {
|
||||
get: (target, property) => {
|
||||
this.#assertAccess();
|
||||
const value: unknown = Reflect.get(target, property, target);
|
||||
if (typeof value !== "function") return value;
|
||||
let method = methods.get(property);
|
||||
if (method === undefined) {
|
||||
method = value.bind(target);
|
||||
methods.set(property, method);
|
||||
}
|
||||
return method;
|
||||
},
|
||||
});
|
||||
this.#handles.set(service.id, handle);
|
||||
}
|
||||
return handle as T;
|
||||
}
|
||||
}
|
||||
|
||||
class StagedServiceInstances<T> implements ServiceInstances<T> {
|
||||
readonly #service: Service<T>;
|
||||
readonly #lifecycle: FacetLifecycle;
|
||||
|
|
@ -254,7 +315,7 @@ class StagedServiceInstances<T> implements ServiceInstances<T> {
|
|||
this.#provider = provider;
|
||||
}
|
||||
|
||||
add(key: string, implementation: T): () => void {
|
||||
add(key: string, implementation: RemoteServiceContract<T>): () => void {
|
||||
this.#lifecycle.assertActive("add service instances");
|
||||
if (this.#provider === undefined) throw new Error("Facet service provider is not connected");
|
||||
const close = this.#provider.spawn(this.#service, key, implementation);
|
||||
|
|
@ -273,17 +334,18 @@ interface KeyedProvision {
|
|||
connect(provider: RemoteServiceProvider): void;
|
||||
}
|
||||
|
||||
type FacetOptions<TAttributes extends object> = {
|
||||
readonly facets: readonly Facet<TAttributes>[];
|
||||
interface FacetOptions {
|
||||
readonly facets: readonly Facet[];
|
||||
readonly bindings?: readonly FacetServiceBinding[];
|
||||
readonly onError?: (error: Error) => void;
|
||||
} & (keyof TAttributes extends never ? { readonly attributes?: TAttributes } : { readonly attributes: TAttributes });
|
||||
}
|
||||
|
||||
type RemoteFacetOptions<TAttributes extends object> = FacetOptions<TAttributes> & {
|
||||
interface RemoteFacetOptions extends FacetOptions {
|
||||
readonly source: RemoteServiceNamespaceApi;
|
||||
readonly connect?: () => void | Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
type FacetKernelOptions<TAttributes extends object> = FacetOptions<TAttributes> &
|
||||
type FacetKernelOptions = FacetOptions &
|
||||
(
|
||||
| { readonly kind: "provider" }
|
||||
| {
|
||||
|
|
@ -293,39 +355,36 @@ type FacetKernelOptions<TAttributes extends object> = FacetOptions<TAttributes>
|
|||
}
|
||||
);
|
||||
|
||||
export interface FacetGeneration {
|
||||
export interface FacetHost {
|
||||
readonly services: RemoteServiceProvider;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FacetServiceGeneration extends FacetGeneration {
|
||||
readonly provider: RemoteServiceProvider;
|
||||
export interface RemoteFacetActivation {
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Assemble and activate facets that provide and consume services within one process. */
|
||||
export async function assembleFacetServices<TAttributes extends object = object>(
|
||||
options: FacetOptions<TAttributes>,
|
||||
): Promise<FacetServiceGeneration> {
|
||||
const kernel = new FacetKernel<TAttributes>({ ...options, kind: "provider" });
|
||||
/** Create an active local host for facets that provide and consume services. */
|
||||
export async function createFacetHost(options: FacetOptions): Promise<FacetHost> {
|
||||
const kernel = new FacetKernel({ ...options, kind: "provider" });
|
||||
await kernel.activate();
|
||||
return Object.freeze({
|
||||
provider: kernel.provider,
|
||||
services: kernel.provider,
|
||||
dispose: () => kernel.dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Activate facets whose services come from an existing remote binding. */
|
||||
export async function activateRemoteFacets<TAttributes extends object = object>(
|
||||
options: RemoteFacetOptions<TAttributes>,
|
||||
): Promise<FacetGeneration> {
|
||||
const kernel = new FacetKernel<TAttributes>({ ...options, kind: "remote" });
|
||||
export async function activateRemoteFacets(options: RemoteFacetOptions): Promise<RemoteFacetActivation> {
|
||||
const kernel = new FacetKernel({ ...options, kind: "remote" });
|
||||
await kernel.activate();
|
||||
return Object.freeze({ dispose: () => kernel.dispose() });
|
||||
}
|
||||
|
||||
/** Private lifecycle and dependency kernel shared by the two atomic entry points. */
|
||||
class FacetKernel<TAttributes extends object> {
|
||||
readonly #facets: readonly Facet<TAttributes>[];
|
||||
readonly #attributes: TAttributes;
|
||||
class FacetKernel {
|
||||
readonly #facets: readonly Facet[];
|
||||
readonly #bindings: FacetServiceBindings;
|
||||
readonly #source: RemoteServiceNamespaceApi | undefined;
|
||||
readonly #connect: (() => void | Promise<void>) | undefined;
|
||||
readonly #onError: (error: Error) => void;
|
||||
|
|
@ -337,12 +396,12 @@ class FacetKernel<TAttributes extends object> {
|
|||
#provider: RemoteServiceProvider | undefined;
|
||||
#phase: GenerationPhase = "setup";
|
||||
|
||||
constructor(options: FacetKernelOptions<TAttributes>) {
|
||||
constructor(options: FacetKernelOptions) {
|
||||
const ids = options.facets.map((facet) => facet.id);
|
||||
if (ids.some((id) => id.length === 0)) throw new Error("Facet ID must not be empty");
|
||||
if (new Set(ids).size !== ids.length) throw new Error("Facet IDs must be unique within a generation");
|
||||
this.#facets = options.facets;
|
||||
this.#attributes = options.attributes ?? ({} as TAttributes);
|
||||
this.#bindings = new FacetServiceBindings(options.bindings ?? [], () => this.#assertServiceAccess());
|
||||
this.#source = options.kind === "remote" ? options.source : undefined;
|
||||
this.#connect = options.kind === "remote" ? options.connect : undefined;
|
||||
this.#onError = options.onError ?? (() => {});
|
||||
|
|
@ -362,7 +421,7 @@ class FacetKernel<TAttributes extends object> {
|
|||
const lifecycle = new FacetLifecycle(facet.id);
|
||||
const ledger = new FacetLedger(facet.id);
|
||||
this.#lifecycles.set(facet.id, lifecycle);
|
||||
const result: unknown = facet.setup(Object.assign(this.#environment(lifecycle, ledger), this.#attributes));
|
||||
const result: unknown = facet.setup(this.#environment(lifecycle, ledger));
|
||||
if (isPromiseLike(result)) {
|
||||
void Promise.resolve(result).catch(() => {});
|
||||
throw new Error(`Facet ${facet.id} setup must be synchronous`);
|
||||
|
|
@ -373,7 +432,7 @@ class FacetKernel<TAttributes extends object> {
|
|||
|
||||
this.#phase = "assembling";
|
||||
if (this.#source === undefined) {
|
||||
this.#activationOrder = validateFacets(records);
|
||||
this.#activationOrder = validateFacets(records, this.#bindings.serviceIds);
|
||||
this.#assembleProvider();
|
||||
} else {
|
||||
this.#activationOrder = records.map((record) => record.facetId);
|
||||
|
|
@ -420,7 +479,7 @@ class FacetKernel<TAttributes extends object> {
|
|||
|
||||
#environment(lifecycle: FacetLifecycle, ledger: FacetLedger): FacetEnvironment {
|
||||
return {
|
||||
provide: <T>(service: Service<T>, implementation: NoInfer<T>): void => {
|
||||
provide: <T>(service: Service<T>, implementation: NoInfer<RemoteServiceContract<T>>): void => {
|
||||
lifecycle.assertSettingUp("provide services");
|
||||
this.#assertProviderGeneration(lifecycle);
|
||||
ledger.provide(service, "singleton");
|
||||
|
|
@ -439,8 +498,9 @@ class FacetKernel<TAttributes extends object> {
|
|||
},
|
||||
use: <T>(service: Service<T>): T => {
|
||||
lifecycle.assertSettingUp("acquire services");
|
||||
if (this.#source !== undefined) return this.#source.use(service);
|
||||
ledger.require(service, "singleton");
|
||||
if (this.#bindings.has(service)) return this.#bindings.use(service);
|
||||
if (this.#source !== undefined) return this.#source.use(service);
|
||||
return this.#localHandles.use(service);
|
||||
},
|
||||
observe: <T>(
|
||||
|
|
@ -528,8 +588,9 @@ class FacetKernel<TAttributes extends object> {
|
|||
}
|
||||
}
|
||||
|
||||
function validateFacets(records: readonly FacetRecord[]): string[] {
|
||||
const providers = new Map<string, { readonly facetId: string; readonly mode: ServiceMode }>();
|
||||
function validateFacets(records: readonly FacetRecord[], boundServices: ReadonlySet<string>): string[] {
|
||||
const providers = new Map<string, { readonly facetId: string | undefined; readonly mode: ServiceMode }>();
|
||||
for (const serviceId of boundServices) providers.set(serviceId, { facetId: undefined, mode: "singleton" });
|
||||
for (const record of records) {
|
||||
for (const provision of record.provides) {
|
||||
const existing = providers.get(provision.serviceId);
|
||||
|
|
@ -537,6 +598,9 @@ function validateFacets(records: readonly FacetRecord[]): string[] {
|
|||
if (existing.mode !== provision.mode) {
|
||||
throw new Error(`Service ${provision.serviceId} is provided as both singleton and keyed`);
|
||||
}
|
||||
if (existing.facetId === undefined) {
|
||||
throw new Error(`Service ${provision.serviceId} is provided by both the host and ${record.facetId}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Service ${provision.serviceId} is provided by both ${existing.facetId} and ${record.facetId}`,
|
||||
);
|
||||
|
|
@ -557,10 +621,10 @@ function validateFacets(records: readonly FacetRecord[]): string[] {
|
|||
}
|
||||
if (provider.mode !== requirement.mode) {
|
||||
throw new Error(
|
||||
`Facet ${record.facetId} requires ${requirement.serviceId} as ${requirement.mode}, but ${provider.facetId} provides it as ${provider.mode}`,
|
||||
`Facet ${record.facetId} requires ${requirement.serviceId} as ${requirement.mode}, but ${provider.facetId ?? "the host"} provides it as ${provider.mode}`,
|
||||
);
|
||||
}
|
||||
if (provider.facetId === record.facetId) continue;
|
||||
if (provider.facetId === undefined || provider.facetId === record.facetId) continue;
|
||||
dependencies.get(record.facetId)!.add(provider.facetId);
|
||||
dependents.get(provider.facetId)!.add(record.facetId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@
|
|||
|
||||
With `PI_EXPERIMENTAL=1`, an interactive `pi client` opens the service-only TUI. It lists the repository-backed `SessionDirectory`, creates or switches Sessions through `SessionManagement`, and selects from the attached worker's `Models` state without invoking Harness drive.
|
||||
|
||||
The Session worker assembles its built-in providers and `SessionWorkerRuntime.facets` atomically with `assembleFacetServices()` from `../facets.ts`. The service-only TUI activates its server and Session facets through `activateRemoteFacets()`. Facets use one `defineFacet()` shape and receive one environment containing the common service/lifecycle API plus host attributes. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and disposal runs in reverse dependency order for the complete generation.
|
||||
The Session worker creates one active `FacetHost` for its built-in providers and `SessionWorkerRuntime.facets` with `createFacetHost()` from `../facets.ts`. The host exposes the services provided by those facets and owns their lifecycle. The service-only TUI activates its server and Session facets through `activateRemoteFacets()`, which returns a disposable `RemoteFacetActivation`. Hosts bind their concrete local services before activation, and facets declare every local or remote dependency through the same `env.use()` operation. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and disposal runs in reverse dependency order for the complete generation.
|
||||
|
||||
A facet always calls unqualified `env.use()` or `env.observe()`. Its facet kind and host select the local, connected-server, or selected-Session service source. Transport namespaces remain internal binding machinery rather than part of the facet environment. TUI facets own subscriptions to their remote replicated state and events; `ExperimentalClientTui` retains only terminal rendering, navigation, and action dispatch. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, and canvas examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { PromptArguments } from "@earendil-works/pi-protocol";
|
|||
import { defineFacet } from "../facets.ts";
|
||||
import { toHarnessPromptArguments } from "../harness-wire-adapter.ts";
|
||||
import { Chat, type ChatPromptRequest, type ChatPromptResponse, type Chat as ChatService } from "./chat.ts";
|
||||
import type { SessionFacetAttributes } from "./session-facet.ts";
|
||||
import { Lane } from "./harness.ts";
|
||||
|
||||
export function createChatService(lane: AgentLane): ChatService {
|
||||
return {
|
||||
|
|
@ -47,10 +47,10 @@ export function createChatService(lane: AgentLane): ChatService {
|
|||
};
|
||||
}
|
||||
|
||||
export const chatServiceFacet = defineFacet<SessionFacetAttributes>({
|
||||
export const chatServiceFacet = defineFacet({
|
||||
id: "@pi/chat",
|
||||
setup(env) {
|
||||
env.provide(Chat, createChatService(env.lane));
|
||||
env.provide(Chat, createChatService(env.use(Lane)));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
import { type AgentHarness, type AgentLane, defineService } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export const Harness = defineService<AgentHarness>("pi.local.harness");
|
||||
export const Lane = defineService<AgentLane>("pi.local.lane");
|
||||
|
|
@ -2,13 +2,31 @@ import {
|
|||
type AgentLane,
|
||||
BACKGROUND_CONTEXT,
|
||||
type Context,
|
||||
defineService,
|
||||
type MutableReplicatedState,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import type { ModelRuntime } from "../../core/model-runtime.ts";
|
||||
import { defineFacet } from "../facets.ts";
|
||||
import { Lane } from "./harness.ts";
|
||||
import { Models, type Models as ModelsService, type ModelsState } from "./models.ts";
|
||||
import type { SessionFacetAttributes } from "./session-facet.ts";
|
||||
|
||||
export interface ModelsRuntime {
|
||||
getAvailableSnapshot(): ReturnType<ModelRuntime["getAvailableSnapshot"]>;
|
||||
refresh(options?: Parameters<ModelRuntime["refresh"]>[0]): ReturnType<ModelRuntime["refresh"]> | undefined;
|
||||
getModel(...args: Parameters<ModelRuntime["getModel"]>): ReturnType<ModelRuntime["getModel"]>;
|
||||
}
|
||||
export const ModelsRuntime = defineService<ModelsRuntime>("pi.local.models-runtime");
|
||||
|
||||
export function createModelsRuntime(runtime: ModelRuntime | undefined): ModelsRuntime {
|
||||
return (
|
||||
runtime ?? {
|
||||
getAvailableSnapshot: () => [],
|
||||
refresh: () => undefined,
|
||||
getModel: () => undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModelsServiceRuntime {
|
||||
readonly service: ModelsService;
|
||||
|
|
@ -17,7 +35,7 @@ export interface ModelsServiceRuntime {
|
|||
|
||||
export function createModelsService(
|
||||
lane: AgentLane,
|
||||
modelRuntime: ModelRuntime | undefined,
|
||||
modelRuntime: ModelsRuntime,
|
||||
createState: (initial: ModelsState) => MutableReplicatedState<ModelsState>,
|
||||
): ModelsServiceRuntime {
|
||||
let catalogRevision = 0;
|
||||
|
|
@ -35,7 +53,7 @@ export function createModelsService(
|
|||
};
|
||||
const readCatalog = async (context: Context): Promise<ModelsState["catalog"]> => {
|
||||
const selected = await lane.getModel(context);
|
||||
const available = modelRuntime?.getAvailableSnapshot() ?? [];
|
||||
const available = modelRuntime.getAvailableSnapshot();
|
||||
const catalog =
|
||||
selected === undefined || includesModel(available, selected) ? available : [...available, selected];
|
||||
catalogRevision += 1;
|
||||
|
|
@ -63,7 +81,8 @@ export function createModelsService(
|
|||
},
|
||||
async refresh(context) {
|
||||
state.set({ ...state.value, refresh: { status: "refreshing" } }, context);
|
||||
if (modelRuntime === undefined) {
|
||||
const refresh = modelRuntime.refresh({ signal: context.abortSignal });
|
||||
if (refresh === undefined) {
|
||||
state.set(
|
||||
{
|
||||
...state.value,
|
||||
|
|
@ -75,7 +94,7 @@ export function createModelsService(
|
|||
);
|
||||
return;
|
||||
}
|
||||
const result = await modelRuntime.refresh({ signal: context.abortSignal });
|
||||
const result = await refresh;
|
||||
const errors = Object.fromEntries([...result.errors].map(([id, error]) => [id, error.message]));
|
||||
state.set(
|
||||
{
|
||||
|
|
@ -88,7 +107,7 @@ export function createModelsService(
|
|||
);
|
||||
},
|
||||
async select(model, context) {
|
||||
const selected = modelRuntime?.getModel(model.provider, model.modelId);
|
||||
const selected = modelRuntime.getModel(model.provider, model.modelId);
|
||||
if (selected === undefined) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`);
|
||||
await lane.setModel({ provider: selected.provider, modelId: selected.id }, context);
|
||||
state.set({ ...state.value, configuration: await readConfiguration(context) }, context);
|
||||
|
|
@ -103,10 +122,10 @@ export function createModelsService(
|
|||
};
|
||||
}
|
||||
|
||||
export const modelsServiceFacet = defineFacet<SessionFacetAttributes>({
|
||||
export const modelsServiceFacet = defineFacet({
|
||||
id: "@pi/models",
|
||||
setup(env) {
|
||||
const runtime = createModelsService(env.lane, env.modelRuntime, env.remoteState);
|
||||
const runtime = createModelsService(env.use(Lane), env.use(ModelsRuntime), env.remoteState);
|
||||
env.provide(Models, runtime.service);
|
||||
env.onActivate(() => runtime.activate(BACKGROUND_CONTEXT));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import type { AgentHarness, AgentLane } from "@earendil-works/pi-agent-core";
|
||||
import type { ModelRuntime } from "../../core/model-runtime.ts";
|
||||
import type { Facet } from "../facets.ts";
|
||||
|
||||
/** Host capabilities added to the common environment for one Session facet. */
|
||||
export interface SessionFacetAttributes {
|
||||
readonly harness: AgentHarness;
|
||||
readonly lane: AgentLane;
|
||||
readonly modelRuntime: ModelRuntime | undefined;
|
||||
}
|
||||
|
||||
export type SessionFacet = Facet<SessionFacetAttributes>;
|
||||
|
|
@ -16,10 +16,10 @@ import {
|
|||
import Type, { type Static } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import type { ModelRuntime } from "../../core/model-runtime.ts";
|
||||
import { assembleFacetServices } from "../facets.ts";
|
||||
import { bindService, createFacetHost, type Facet } from "../facets.ts";
|
||||
import { chatServiceFacet } from "./chat-provider.ts";
|
||||
import { modelsServiceFacet } from "./models-provider.ts";
|
||||
import type { SessionFacet, SessionFacetAttributes } from "./session-facet.ts";
|
||||
import { Harness, Lane } from "./harness.ts";
|
||||
import { createModelsRuntime, ModelsRuntime, modelsServiceFacet } from "./models-provider.ts";
|
||||
import { accountsServiceFacet, transcriptServiceFacet } from "./stubs-provider.ts";
|
||||
|
||||
export const ServiceOperationResultSchema = Type.Object(
|
||||
|
|
@ -32,7 +32,7 @@ export interface SessionWorkerRuntime {
|
|||
readonly harness: AgentHarness;
|
||||
readonly lane?: AgentLane;
|
||||
readonly modelRuntime?: ModelRuntime;
|
||||
readonly facets?: readonly SessionFacet[];
|
||||
readonly facets?: readonly Facet[];
|
||||
}
|
||||
|
||||
export interface WorkerServiceScope {
|
||||
|
|
@ -56,20 +56,24 @@ const BUILTIN_SESSION_FACETS = [
|
|||
modelsServiceFacet,
|
||||
accountsServiceFacet,
|
||||
transcriptServiceFacet,
|
||||
] satisfies readonly SessionFacet[];
|
||||
] satisfies readonly Facet[];
|
||||
|
||||
export async function createSessionWorkerServices(options: {
|
||||
readonly harness: AgentHarness;
|
||||
readonly lane: AgentLane;
|
||||
readonly modelRuntime: ModelRuntime | undefined;
|
||||
readonly facets: readonly SessionFacet[];
|
||||
readonly facets: readonly Facet[];
|
||||
publish(scope: WorkerServiceScope, subscriptionId: string, update: ProtocolServiceProviderUpdate): Promise<void>;
|
||||
}): Promise<SessionWorkerServices> {
|
||||
const facetServices = await assembleFacetServices<SessionFacetAttributes>({
|
||||
const facetHost = await createFacetHost({
|
||||
facets: [...BUILTIN_SESSION_FACETS, ...options.facets],
|
||||
attributes: { harness: options.harness, lane: options.lane, modelRuntime: options.modelRuntime },
|
||||
bindings: [
|
||||
bindService(Harness, options.harness),
|
||||
bindService(Lane, options.lane),
|
||||
bindService(ModelsRuntime, createModelsRuntime(options.modelRuntime)),
|
||||
],
|
||||
});
|
||||
const provider = facetServices.provider;
|
||||
const provider = facetHost.services;
|
||||
|
||||
const subscriptions = new Map<string, WorkerServiceSubscription>();
|
||||
const removeSubscriptions = (matches: (scope: WorkerServiceScope) => boolean): void => {
|
||||
|
|
@ -106,7 +110,7 @@ export async function createSessionWorkerServices(options: {
|
|||
removeSubscriptions,
|
||||
async dispose() {
|
||||
removeSubscriptions(() => true);
|
||||
await facetServices.dispose();
|
||||
await facetHost.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
type AgentHarness,
|
||||
type AgentLane,
|
||||
BACKGROUND_CONTEXT,
|
||||
Closed,
|
||||
|
|
@ -11,9 +10,9 @@ import {
|
|||
UnknownTemplate,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { assembleFacetServices } from "../src/experimental/facets.ts";
|
||||
import { bindService, createFacetHost } from "../src/experimental/facets.ts";
|
||||
import { chatServiceFacet, toChatPromptResponse } from "../src/experimental/services/chat-provider.ts";
|
||||
import type { SessionFacetAttributes } from "../src/experimental/services/session-facet.ts";
|
||||
import { Lane } from "../src/experimental/services/harness.ts";
|
||||
|
||||
const admissionErrors = [
|
||||
[
|
||||
|
|
@ -56,19 +55,19 @@ describe("Chat service", () => {
|
|||
},
|
||||
}));
|
||||
const lane = { prompt, requestAbort } as unknown as AgentLane;
|
||||
const generation = await assembleFacetServices<SessionFacetAttributes>({
|
||||
const host = await createFacetHost({
|
||||
facets: [chatServiceFacet],
|
||||
attributes: { harness: {} as unknown as AgentHarness, lane, modelRuntime: undefined },
|
||||
bindings: [bindService(Lane, lane)],
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
generation.provider.invoke(
|
||||
host.services.invoke(
|
||||
{ serviceId: "pi.chat", member: "prompt", args: [{ message: "hello", images: null }] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toEqual({ accepted: true, operationId: "operation-1", error: null });
|
||||
await expect(
|
||||
generation.provider.invoke(
|
||||
host.services.invoke(
|
||||
{ serviceId: "pi.chat", member: "requestAbort", args: ["operation-1"] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
|
|
@ -76,13 +75,13 @@ describe("Chat service", () => {
|
|||
expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT);
|
||||
expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT);
|
||||
await expect(
|
||||
generation.provider.invoke(
|
||||
host.services.invoke(
|
||||
{ serviceId: "pi.chat", member: "steer", args: [{ message: "later", images: null }] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ServiceSliceNotImplemented);
|
||||
} finally {
|
||||
await generation.dispose();
|
||||
await host.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type ReplicatedState,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { assembleFacetServices, defineFacet } from "../src/experimental/facets.ts";
|
||||
import { bindService, createFacetHost, defineFacet } from "../src/experimental/facets.ts";
|
||||
|
||||
interface Source {
|
||||
read(context: Context): Promise<string>;
|
||||
|
|
@ -24,12 +24,18 @@ interface Watched {
|
|||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
interface HostValues {
|
||||
readonly name: string;
|
||||
readonly use: string;
|
||||
}
|
||||
|
||||
const Source = defineService<Source>("test.experimental.source");
|
||||
const Projection = defineService<Projection>("test.experimental.projection");
|
||||
const KeyedValue = defineService<KeyedValue>("test.experimental.keyed-value");
|
||||
const Watched = defineService<Watched>("test.experimental.watched");
|
||||
const HostValues = defineService<HostValues>("test.experimental.host-values");
|
||||
|
||||
describe("experimental facet generation", () => {
|
||||
describe("experimental facet host", () => {
|
||||
test("discovers setup dependencies before connecting stable service handles", async () => {
|
||||
const trace: string[] = [];
|
||||
let sourceHandle: Source | undefined;
|
||||
|
|
@ -69,13 +75,13 @@ describe("experimental facet generation", () => {
|
|||
});
|
||||
},
|
||||
});
|
||||
const generation = await assembleFacetServices({ facets: [projection, source] });
|
||||
const host = await createFacetHost({ facets: [projection, source] });
|
||||
|
||||
expect(trace).toEqual(["setup projection", "setup source", "activate source", "activate projection"]);
|
||||
expect(await sourceHandle!.read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
expect(await generation.provider.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
expect(await host.services.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
|
||||
await generation.dispose();
|
||||
await host.dispose();
|
||||
expect(trace.slice(-2)).toEqual(["dispose projection", "dispose source"]);
|
||||
});
|
||||
|
||||
|
|
@ -106,11 +112,11 @@ describe("experimental facet generation", () => {
|
|||
});
|
||||
},
|
||||
});
|
||||
const generation = await assembleFacetServices({ facets: [observer, provider] });
|
||||
const host = await createFacetHost({ facets: [observer, provider] });
|
||||
await vi.waitFor(() => expect(trace).toContain("observe one"));
|
||||
|
||||
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
|
||||
await generation.dispose();
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("owns resources registered during activation", async () => {
|
||||
|
|
@ -136,16 +142,34 @@ describe("experimental facet generation", () => {
|
|||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const generation = await assembleFacetServices({ facets: [consumer, provider] });
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.set({ value: 1 }, BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await generation.dispose();
|
||||
await host.dispose();
|
||||
state!.set({ value: 2 }, BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
test("resolves explicit host service bindings", async () => {
|
||||
const values: HostValues = { name: "session", use: "host value" };
|
||||
const facet = defineFacet({
|
||||
id: "host-service-consumer",
|
||||
setup(env) {
|
||||
const hostValues = env.use(HostValues);
|
||||
expect(() => hostValues.use).toThrow("Facet service handles cannot be used during setup");
|
||||
env.onActivate(() => {
|
||||
expect(hostValues).not.toBe(values);
|
||||
expect(hostValues.name).toBe("session");
|
||||
expect(hostValues.use).toBe("host value");
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [facet], bindings: [bindService(HostValues, values)] });
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("rejects missing dependencies, cycles, and asynchronous setup", async () => {
|
||||
let activated = false;
|
||||
const missing = defineFacet({
|
||||
|
|
@ -157,7 +181,7 @@ describe("experimental facet generation", () => {
|
|||
});
|
||||
},
|
||||
});
|
||||
await expect(assembleFacetServices({ facets: [missing] })).rejects.toThrow(
|
||||
await expect(createFacetHost({ facets: [missing] })).rejects.toThrow(
|
||||
"Facet missing requires local/test.experimental.source/singleton, but no facet provides it",
|
||||
);
|
||||
expect(activated).toBe(false);
|
||||
|
|
@ -184,7 +208,7 @@ describe("experimental facet generation", () => {
|
|||
});
|
||||
},
|
||||
});
|
||||
await expect(assembleFacetServices({ facets: [first, second] })).rejects.toThrow(
|
||||
await expect(createFacetHost({ facets: [first, second] })).rejects.toThrow(
|
||||
"Facet dependency cycle: first, second",
|
||||
);
|
||||
|
||||
|
|
@ -194,7 +218,7 @@ describe("experimental facet generation", () => {
|
|||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
await expect(assembleFacetServices({ facets: [asynchronous] })).rejects.toThrow(
|
||||
await expect(createFacetHost({ facets: [asynchronous] })).rejects.toThrow(
|
||||
"Facet asynchronous setup must be synchronous",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BACKGROUND_CONTEXT, ServiceSliceNotImplemented } from "@earendil-works/pi-agent-core";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { assembleFacetServices } from "../src/experimental/facets.ts";
|
||||
import { createFacetHost } from "../src/experimental/facets.ts";
|
||||
import { BUILTIN_SERVER_SERVICES, BUILTIN_SESSION_SERVICES } from "../src/experimental/services/builtins.ts";
|
||||
import { accountsServiceFacet, transcriptServiceFacet } from "../src/experimental/services/stubs-provider.ts";
|
||||
|
||||
|
|
@ -16,30 +16,27 @@ describe("experimental built-in service surface", () => {
|
|||
});
|
||||
|
||||
test("exposes later singleton slices as explicit unimplemented providers", async () => {
|
||||
const generation = await assembleFacetServices({ facets: [accountsServiceFacet, transcriptServiceFacet] });
|
||||
const host = await createFacetHost({ facets: [accountsServiceFacet, transcriptServiceFacet] });
|
||||
try {
|
||||
const accounts = generation.provider.subscribe("pi.accounts", "singleton", () => {}).snapshot;
|
||||
const accounts = host.services.subscribe("pi.accounts", "singleton", () => {}).snapshot;
|
||||
expect(accounts.instances[0]?.states.state?.value).toEqual({ providers: [] });
|
||||
await expect(
|
||||
generation.provider.invoke(
|
||||
host.services.invoke(
|
||||
{ serviceId: "pi.accounts", member: "remove", args: ["anthropic"] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ServiceSliceNotImplemented);
|
||||
|
||||
const transcript = generation.provider.subscribe("pi.transcript", "singleton", () => {}).snapshot;
|
||||
const transcript = host.services.subscribe("pi.transcript", "singleton", () => {}).snapshot;
|
||||
expect(transcript.instances[0]?.members).toEqual([
|
||||
{ name: "events", kind: "events" },
|
||||
{ name: "snapshot", kind: "method" },
|
||||
]);
|
||||
await expect(
|
||||
generation.provider.invoke(
|
||||
{ serviceId: "pi.transcript", member: "snapshot", args: [] },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
host.services.invoke({ serviceId: "pi.transcript", member: "snapshot", args: [] }, BACKGROUND_CONTEXT),
|
||||
).rejects.toBeInstanceOf(ServiceSliceNotImplemented);
|
||||
} finally {
|
||||
await generation.dispose();
|
||||
await host.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue