From 566b96392415e903644b2dfec528010334dbf13b Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 22 Jul 2026 18:20:06 -0700 Subject: [PATCH 1/3] feat: add Hermes gateway provider --- .../Layers/ProjectionPipeline.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 132 ++ .../Layers/ProviderRuntimeIngestion.ts | 40 +- apps/server/src/orchestration/decider.ts | 4 +- apps/server/src/orchestration/projector.ts | 6 +- .../src/provider/Drivers/HermesDriver.test.ts | 56 + .../src/provider/Drivers/HermesDriver.ts | 235 +++ .../src/provider/Layers/HermesAdapter.test.ts | 753 ++++++++ .../src/provider/Layers/HermesAdapter.ts | 988 +++++++++++ .../provider/Layers/ProviderRegistry.test.ts | 1 + apps/server/src/provider/builtInDrivers.ts | 3 + .../hermes/HermesBridgeClient.test.ts | 197 +++ .../src/provider/hermes/HermesBridgeClient.ts | 90 + .../hermes/HermesBridgeRegistry.test.ts | 110 ++ .../provider/hermes/HermesBridgeRegistry.ts | 77 + apps/server/src/provider/hermes/http.ts | 176 ++ apps/server/src/server.test.ts | 118 ++ apps/server/src/server.ts | 2 + apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 10 +- .../chat/ExpandedImagePreview.test.ts | 36 + .../components/chat/MessagesTimeline.test.tsx | 49 + .../src/components/chat/MessagesTimeline.tsx | 92 +- .../chat/composerSlashCommandSearch.test.ts | 82 +- .../chat/composerSlashCommandSearch.ts | 32 +- .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/ProviderSettingsForm.test.ts | 11 + .../components/settings/providerDriverMeta.ts | 17 +- apps/web/src/composer-logic.test.ts | 11 + apps/web/src/composer-logic.ts | 14 + apps/web/src/session-logic.test.ts | 29 + apps/web/src/session-logic.ts | 4 +- integrations/hermes/t3agent/README.md | 134 ++ integrations/hermes/t3agent/adapter.py | 1556 +++++++++++++++++ integrations/hermes/t3agent/plugin.yaml | 64 + .../hermes/t3agent/tests/test_adapter.py | 1078 ++++++++++++ packages/contracts/package.json | 4 + packages/contracts/src/hermesBridge.test.ts | 440 +++++ packages/contracts/src/hermesBridge.ts | 361 ++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/model.ts | 1 + packages/contracts/src/orchestration.ts | 3 + packages/contracts/src/settings.ts | 62 + 43 files changed, 7033 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/provider/Drivers/HermesDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/HermesDriver.ts create mode 100644 apps/server/src/provider/Layers/HermesAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/HermesAdapter.ts create mode 100644 apps/server/src/provider/hermes/HermesBridgeClient.test.ts create mode 100644 apps/server/src/provider/hermes/HermesBridgeClient.ts create mode 100644 apps/server/src/provider/hermes/HermesBridgeRegistry.test.ts create mode 100644 apps/server/src/provider/hermes/HermesBridgeRegistry.ts create mode 100644 apps/server/src/provider/hermes/http.ts create mode 100644 apps/web/src/components/chat/ExpandedImagePreview.test.ts create mode 100644 integrations/hermes/t3agent/README.md create mode 100644 integrations/hermes/t3agent/adapter.py create mode 100644 integrations/hermes/t3agent/plugin.yaml create mode 100644 integrations/hermes/t3agent/tests/test_adapter.py create mode 100644 packages/contracts/src/hermesBridge.test.ts create mode 100644 packages/contracts/src/hermesBridge.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cfb88a06cd2..048db29a599 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -856,6 +856,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (event.payload.streaming) { return `${message.text}${event.payload.text}`; } + if (event.payload.replaceText) { + return event.payload.text; + } if (event.payload.text.length === 0) { return message.text; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 74ece50cd31..0c26b439738 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -978,6 +978,138 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("uses assistant completion detail as the authoritative final text", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-assistant-rewrite-delta"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-rewrite"), + itemId: asItemId("item-rewrite"), + payload: { + streamKind: "assistant_text", + delta: "answer▌", + }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-rewrite-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-rewrite"), + itemId: asItemId("item-rewrite"), + payload: { + itemType: "assistant_message", + status: "completed", + detail: "answer", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-rewrite" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-rewrite", + ); + expect(message?.text).toBe("answer"); + }); + + it("allows authoritative completion text to clear a stale streamed preview", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-assistant-clear-delta"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + itemId: asItemId("item-clear"), + payload: { streamKind: "assistant_text", delta: "stale preview" }, + }); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-clear-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + itemId: asItemId("item-clear"), + payload: { + itemType: "assistant_message", + status: "completed", + data: { finalText: "" }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-clear" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-clear", + ); + expect(message?.text).toBe(""); + }); + + it("projects structured assistant image attachments without requiring text", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-assistant-image-completed"), + provider: ProviderDriverKind.make("hermes"), + createdAt: now, + threadId: asThreadId("thread-1"), + itemId: asItemId("item-image"), + payload: { + itemType: "assistant_message", + status: "completed", + data: { + attachments: [ + { + type: "image", + id: "thread-1-image-attachment", + name: "result.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-image" && !message.streaming, + ), + ); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-image", + ); + expect(message?.text).toBe(""); + expect(message?.attachments).toEqual([ + { + type: "image", + id: "thread-1-image-attachment", + name: "result.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ]); + }); + it("preserves completed tool metadata on projected tool activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a8a51b30260..9372eb0cc04 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,6 +1,8 @@ import { ApprovalRequestId, type AssistantDeliveryMode, + ChatAttachment, + type ChatAttachment as ChatAttachmentType, CommandId, MessageId, type OrchestrationEvent, @@ -24,6 +26,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -41,6 +44,25 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const isChatAttachment = Schema.is(ChatAttachment); + +function attachmentsFromItemData(data: unknown): ReadonlyArray | undefined { + if (typeof data !== "object" || data === null || !("attachments" in data)) { + return undefined; + } + const attachments = (data as { readonly attachments?: unknown }).attachments; + if (!Array.isArray(attachments)) { + return undefined; + } + const parsed = attachments.filter(isChatAttachment); + return parsed.length > 0 ? parsed : undefined; +} + +function finalTextFromItemData(data: unknown): string | undefined { + if (typeof data !== "object" || data === null || !("finalText" in data)) return undefined; + const finalText = (data as { readonly finalText?: unknown }).finalText; + return typeof finalText === "string" ? finalText : undefined; +} // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -694,9 +716,7 @@ const make = Effect.gen(function* () { const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), - ); + Effect.succeed(CommandId.make(`provider:${event.eventId}:${tag}`)); const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1006,6 +1026,8 @@ const make = Effect.gen(function* () { commandTag: string; finalDeltaCommandTag: string; fallbackText?: string; + finalText?: string; + attachments?: ReadonlyArray; hasProjectedMessage?: boolean; }) => Effect.gen(function* () { @@ -1030,12 +1052,14 @@ const make = Effect.gen(function* () { }); } - if (input.hasProjectedMessage || hasRenderableText) { + if (input.hasProjectedMessage || hasRenderableText || (input.attachments?.length ?? 0) > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.complete", commandId: yield* providerCommandId(input.event, input.commandTag), threadId: input.threadId, messageId: input.messageId, + ...(input.finalText !== undefined ? { text: input.finalText } : {}), + ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), ...(input.turnId ? { turnId: input.turnId } : {}), createdAt: input.createdAt, }); @@ -1558,6 +1582,8 @@ const make = Effect.gen(function* () { `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ), fallbackText: event.payload.detail, + finalText: finalTextFromItemData(event.payload.data) ?? event.payload.detail, + attachments: attachmentsFromItemData(event.payload.data), } : undefined; const proposedPlanCompletion = @@ -1609,6 +1635,12 @@ const make = Effect.gen(function* () { ...(assistantCompletion.fallbackText !== undefined && shouldApplyFallbackCompletionText ? { fallbackText: assistantCompletion.fallbackText } : {}), + ...(assistantCompletion.finalText !== undefined + ? { finalText: assistantCompletion.finalText } + : {}), + ...(assistantCompletion.attachments !== undefined + ? { attachments: assistantCompletion.attachments } + : {}), }); if (turnId) { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index cba967afc7c..a4033cd706e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -891,7 +891,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, messageId: command.messageId, role: "assistant", - text: "", + text: command.text ?? "", + ...(command.text !== undefined ? { replaceText: true } : {}), + ...(command.attachments !== undefined ? { attachments: command.attachments } : {}), turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c8f47dcebbf..a9fe821dbc0 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -445,9 +445,11 @@ export function projectEvent( ...entry, text: message.streaming ? `${entry.text}${message.text}` - : message.text.length > 0 + : payload.replaceText ? message.text - : entry.text, + : message.text.length > 0 + ? message.text + : entry.text, streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, diff --git a/apps/server/src/provider/Drivers/HermesDriver.test.ts b/apps/server/src/provider/Drivers/HermesDriver.test.ts new file mode 100644 index 00000000000..bc345945448 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.test.ts @@ -0,0 +1,56 @@ +import { assert, it } from "@effect/vitest"; +import { HermesBridgeRequestId, ProviderInstanceId } from "@t3tools/contracts"; + +import { makeHermesProviderSnapshot } from "./HermesDriver.ts"; + +it("surfaces the active Hermes identity and canonical commands plus aliases", () => { + const snapshot = makeHermesProviderSnapshot({ + instanceId: ProviderInstanceId.make("hermes"), + displayName: undefined, + accentColor: undefined, + enabled: true, + checkedAt: "2026-07-22T00:00:00.000Z", + capabilities: { + protocolVersion: 1, + requestId: HermesBridgeRequestId.make("capabilities"), + capabilities: { + asynchronousDelivery: true, + imageAttachments: true, + interrupts: true, + approvals: true, + clarifications: true, + slashConfirmations: true, + threadCreation: true, + commandCatalog: true, + }, + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + profile: "default", + commands: [ + { + name: "new", + description: "Start a new conversation", + inputHint: "[name]", + aliases: ["reset"], + }, + ], + }, + }); + + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.label, "default"); + assert.deepEqual(snapshot.models, [ + { + slug: "anthropic/claude-sonnet-4", + name: "anthropic/claude-sonnet-4", + subProvider: "openrouter", + isCustom: true, + isDefault: true, + capabilities: null, + }, + ]); + assert.deepEqual( + snapshot.slashCommands.map(({ name }) => name), + ["new", "reset"], + ); +}); diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..4c1eeb5fcc4 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,235 @@ +import { + HermesSettings, + ProviderDriverKind, + TextGenerationError, + type HermesBridgeCapabilitiesResponse, + type ServerProvider, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; + +import { ServerConfig } from "../../config.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderDriverCreateInput, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { makeHermesBridgeClient } from "../hermes/HermesBridgeClient.ts"; +import * as HermesBridgeRegistry from "../hermes/HermesBridgeRegistry.ts"; +import type { ServerProviderShape } from "../Services/ServerProvider.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const decodeSettings = Schema.decodeSync(HermesSettings); + +export function makeHermesProviderSnapshot(input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly enabled: boolean; + readonly capabilities?: HermesBridgeCapabilitiesResponse; + readonly error?: string; + readonly checkedAt: string; +}): ServerProvider { + const activeModel = input.capabilities?.model?.trim() || "active"; + const provider = input.capabilities?.provider?.trim(); + return { + instanceId: input.instanceId, + driver: DRIVER_KIND, + displayName: input.displayName ?? "Hermes", + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + badgeLabel: "Agent", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, + enabled: input.enabled, + installed: true, + version: null, + status: input.enabled ? (input.error ? "error" : "ready") : "disabled", + auth: { + status: input.error ? "unknown" : "authenticated", + type: "local-bridge", + ...(input.capabilities?.profile ? { label: input.capabilities.profile } : {}), + }, + checkedAt: input.checkedAt, + ...(input.error ? { message: input.error } : {}), + availability: "available", + models: [ + { + slug: activeModel, + name: activeModel === "active" ? "Active Hermes model" : activeModel, + ...(provider ? { subProvider: provider } : {}), + isCustom: true, + isDefault: true, + capabilities: null, + }, + ], + slashCommands: (input.capabilities?.commands ?? []).flatMap((command) => + [command.name, ...(command.aliases ?? [])].map((name) => ({ + name: name.replace(/^\/+/, ""), + ...(command.description ? { description: command.description } : {}), + ...(command.inputHint ? { input: { hint: command.inputHint } } : {}), + })), + ), + skills: [], + continuation: { groupKey: `hermes:instance:${input.instanceId}` }, + }; +} + +const unsupportedTextGeneration = { + generateCommitMessage: () => + Effect.fail( + new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Hermes is a conversation gateway and does not generate T3 git metadata.", + }), + ), + generatePrContent: () => + Effect.fail( + new TextGenerationError({ + operation: "generatePrContent", + detail: "Hermes is a conversation gateway and does not generate T3 git metadata.", + }), + ), + generateBranchName: () => + Effect.fail( + new TextGenerationError({ + operation: "generateBranchName", + detail: "Hermes conversations are not project branches.", + }), + ), + generateThreadTitle: () => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Hermes thread titles are owned by the gateway conversation.", + }), + ), +}; + +export type HermesDriverEnv = + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ServerConfig; + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: () => decodeSettings({}), + create: Effect.fn("HermesDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + enabled, + config, + }: ProviderDriverCreateInput) { + const httpClient = yield* HttpClient.HttpClient; + const client = makeHermesBridgeClient({ + baseUrl: config.bridgeUrl, + token: config.ingressToken, + httpClient, + }); + const adapter = yield* makeHermesAdapter({ instanceId, client }); + yield* HermesBridgeRegistry.register(instanceId, { + token: config.callbackToken, + receive: adapter.receiveCallback, + }); + yield* Effect.addFinalizer(() => HermesBridgeRegistry.unregister(instanceId)); + + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const initial = makeHermesProviderSnapshot({ + instanceId, + displayName, + accentColor, + enabled, + error: + config.ingressToken && config.callbackToken + ? "Waiting for the Hermes T3 Agent plugin." + : "Configure both Hermes bridge tokens.", + checkedAt, + }); + const snapshotRef = yield* Ref.make(initial); + const changes = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + const refresh = client.getCapabilities.pipe( + Effect.matchEffect({ + onFailure: Effect.fn("HermesDriver.refreshFailure")(function* () { + const next = makeHermesProviderSnapshot({ + instanceId, + displayName, + accentColor, + enabled, + error: "Hermes bridge is unavailable.", + checkedAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* Ref.set(snapshotRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + onSuccess: Effect.fn("HermesDriver.refreshSuccess")(function* ( + capabilities: HermesBridgeCapabilitiesResponse, + ) { + const next = makeHermesProviderSnapshot({ + instanceId, + displayName, + accentColor, + enabled, + capabilities, + checkedAt: DateTime.formatIso(yield* DateTime.now), + }); + yield* Ref.set(snapshotRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + }), + ); + if (enabled && config.ingressToken && config.callbackToken) { + yield* Effect.forever(Effect.sleep("30 seconds").pipe(Effect.andThen(refresh))).pipe( + Effect.forkScoped, + ); + yield* refresh.pipe(Effect.forkScoped); + } + + const snapshot: ServerProviderShape = { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSnapshot: Ref.get(snapshotRef), + refresh, + streamChanges: Stream.fromPubSub(changes), + }; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration: unsupportedTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts new file mode 100644 index 00000000000..bebecd53dc4 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -0,0 +1,753 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + ApprovalRequestId, + HERMES_BRIDGE_PROTOCOL_VERSION, + HermesBridgeRequestId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type HermesBridgeT3ToHermesRequest, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import type { HermesBridgeClient } from "../hermes/HermesBridgeClient.ts"; +import { type HermesAdapter, makeHermesAdapter } from "./HermesAdapter.ts"; + +interface HermesAdapterTestHarnessShape { + readonly adapter: HermesAdapter; + readonly client: HermesBridgeClient; + readonly sent: Array; +} + +class HermesAdapterTestHarness extends Context.Service< + HermesAdapterTestHarness, + HermesAdapterTestHarnessShape +>()("t3/provider/Layers/HermesAdapter.test/HermesAdapterTestHarness") {} + +const testLayer = Layer.effect( + HermesAdapterTestHarness, + Effect.gen(function* () { + const sent: Array = []; + const client: HermesBridgeClient = { + getCapabilities: Effect.succeed({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make("test-capabilities"), + capabilities: { + asynchronousDelivery: true, + imageAttachments: true, + interrupts: true, + approvals: true, + clarifications: true, + slashConfirmations: true, + threadCreation: true, + commandCatalog: true, + }, + commands: [], + }), + send: (request) => + Effect.sync(() => { + sent.push(request); + return { + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: request.requestId, + status: "accepted" as const, + }; + }), + }; + const adapter = yield* makeHermesAdapter({ + instanceId: ProviderInstanceId.make("hermes-test"), + client, + }); + return HermesAdapterTestHarness.of({ adapter, client, sent }); + }), +).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-hermes-adapter-test-" })), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(testLayer)("HermesAdapter", (it) => { + it.effect("routes turns by T3 thread identity without forwarding cwd", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-route-thread"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + providerInstanceId: ProviderInstanceId.make("hermes-test"), + threadId, + cwd: "/workspace/that-must-not-be-forwarded", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "hello Hermes" }); + + NodeAssert.equal(session.cwd, undefined); + NodeAssert.equal(sent.length, 1); + const request = sent[0]; + NodeAssert.equal(request?.type, "message.submit"); + if (request?.type !== "message.submit") return; + NodeAssert.equal(request.chatId, "t3agent"); + NodeAssert.equal(request.threadId, threadId); + NodeAssert.equal(request.content, "hello Hermes"); + NodeAssert.equal("cwd" in request, false); + }), + ); + + it.effect("emits only cumulative text deltas and completes the active turn", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-stream-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const turn = yield* adapter.sendTurn({ threadId, input: "stream a reply" }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "callback-request-1", + deliveryId: "callback-delivery-1", + type: "message.send", + threadId, + sourceMessageId: `hermes-user:${turn.turnId}`, + messageId: "hermes-message-1", + content: "Hel", + final: false, + }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "callback-request-2", + deliveryId: "callback-delivery-2", + type: "message.edit", + threadId, + sourceMessageId: `hermes-user:${turn.turnId}`, + messageId: "hermes-message-1", + content: "Hello", + final: true, + }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "callback-request-3", + deliveryId: "callback-delivery-3", + type: "turn.complete", + threadId, + sourceMessageId: `hermes-user:${turn.turnId}`, + outcome: "success", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["turn.started", "content.delta", "content.delta", "item.completed", "turn.completed"], + ); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => event.payload.delta), + ["Hel", "lo"], + ); + const completed = events.find((event) => event.type === "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + NodeAssert.equal(completed.payload.detail, "Hello"); + } + }), + ); + + it.effect("acknowledges a repeated delivery id as a duplicate", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-duplicate-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const callback = { + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "duplicate-request", + deliveryId: "duplicate-delivery", + type: "typing.set", + threadId, + active: true, + } as const; + + const accepted = yield* adapter.receiveCallback(callback); + const duplicate = yield* adapter.receiveCallback(callback); + + NodeAssert.deepEqual(accepted, { + status: "accepted", + deliveryId: "duplicate-delivery", + }); + NodeAssert.deepEqual(duplicate, { + status: "duplicate", + deliveryId: "duplicate-delivery", + }); + }), + ); + + it.effect("round-trips approval and clarification responses through the bridge", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-interaction-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "approval-request-callback", + deliveryId: "approval-request-delivery", + type: "approval.request", + threadId, + sessionKey: "session-interactions", + providerRequestId: "provider-approval-1", + approvalId: "approval-1", + title: "Run command", + message: "Allow this command?", + choices: [ + { id: "once", label: "Allow once" }, + { id: "session", label: "Allow for this session" }, + { id: "always", label: "Always allow" }, + { id: "deny", label: "Deny" }, + ], + }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "clarification-request-callback", + deliveryId: "clarification-request-delivery", + type: "clarification.request", + threadId, + sessionKey: "session-interactions", + providerRequestId: "provider-clarification-1", + clarifyId: "clarify-1", + question: "Which environment?", + choices: [{ id: "staging", label: "Staging" }], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["user-input.requested", "user-input.requested"], + ); + + const approvalEvent = events[0]; + const clarificationEvent = events[1]; + NodeAssert.equal(approvalEvent?.type, "user-input.requested"); + NodeAssert.equal(clarificationEvent?.type, "user-input.requested"); + if ( + approvalEvent?.type !== "user-input.requested" || + clarificationEvent?.type !== "user-input.requested" + ) { + return; + } + + NodeAssert.ok(approvalEvent.requestId); + NodeAssert.ok(clarificationEvent.requestId); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(approvalEvent.requestId), { + "approval-1": "Always allow", + }); + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(clarificationEvent.requestId), + { "clarify-1": "Staging" }, + ); + + NodeAssert.equal(sent[0]?.type, "approval.respond"); + NodeAssert.deepEqual( + sent[0]?.type === "approval.respond" + ? { + sessionKey: sent[0].sessionKey, + approvalId: sent[0].approvalId, + providerRequestId: sent[0].providerRequestId, + choice: sent[0].choice, + } + : undefined, + { + sessionKey: "session-interactions", + approvalId: "approval-1", + providerRequestId: "provider-approval-1", + choice: "always", + }, + ); + NodeAssert.equal(sent[1]?.type, "clarification.respond"); + NodeAssert.deepEqual( + sent[1]?.type === "clarification.respond" + ? { + sessionKey: sent[1].sessionKey, + clarifyId: sent[1].clarifyId, + providerRequestId: sent[1].providerRequestId, + response: sent[1].response, + } + : undefined, + { + sessionKey: "session-interactions", + clarifyId: "clarify-1", + providerRequestId: "provider-clarification-1", + response: "Staging", + }, + ); + }), + ); + + it.effect("presents slash confirmations as resolvable choices", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-confirmation-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "confirmation-callback", + deliveryId: "confirmation-delivery", + type: "slash-confirmation.request", + threadId, + sessionKey: "session-confirmation", + confirmId: "confirm-1", + title: "Confirm restart", + message: "Restart Hermes?", + }); + + const events = Array.from(yield* Fiber.join(eventFiber)); + NodeAssert.equal(events[0]?.type, "user-input.requested"); + if (events[0]?.type !== "user-input.requested") return; + NodeAssert.ok(events[0].requestId); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make(events[0].requestId), { + "confirm-1": "Run once", + }); + NodeAssert.equal(sent[0]?.type, "slash-confirmation.respond"); + if (sent[0]?.type !== "slash-confirmation.respond") return; + NodeAssert.equal(sent[0].sessionKey, "session-confirmation"); + NodeAssert.equal(sent[0].confirmId, "confirm-1"); + NodeAssert.equal(sent[0].choice, "once"); + }), + ); + + it.effect("rehydrates an interactive resolver from its persisted request id after restart", () => + Effect.gen(function* () { + const { adapter, client, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-restart-interaction-thread"); + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "restart-approval-callback", + deliveryId: "restart-approval-delivery", + type: "approval.request", + threadId, + sessionKey: "session-after-restart", + providerRequestId: "provider-request-after-restart", + approvalId: "approval-after-restart", + message: "Allow after restart?", + choices: [ + { id: "once", label: "Allow once" }, + { id: "deny", label: "Deny" }, + ], + }); + const events = Array.from(yield* Fiber.join(eventFiber)); + const request = events[0]; + NodeAssert.equal(request?.type, "user-input.requested"); + if (request?.type !== "user-input.requested") return; + NodeAssert.ok(request.requestId); + + const restarted = yield* makeHermesAdapter({ + instanceId: ProviderInstanceId.make("hermes-test"), + client, + }); + yield* restarted.respondToUserInput(threadId, ApprovalRequestId.make(request.requestId), { + "approval-after-restart": "Allow once", + }); + + const response = sent[0]; + NodeAssert.equal(response?.type, "approval.respond"); + if (response?.type !== "approval.respond") return; + NodeAssert.equal(response.sessionKey, "session-after-restart"); + NodeAssert.equal(response.approvalId, "approval-after-restart"); + NodeAssert.equal(response.providerRequestId, "provider-request-after-restart"); + NodeAssert.equal(response.choice, "once"); + }), + ); + + it.effect("correlates a delayed completion to its source turn after restart", () => + Effect.gen(function* () { + const { client } = yield* HermesAdapterTestHarness; + const restarted = yield* makeHermesAdapter({ + instanceId: ProviderInstanceId.make("hermes-test"), + client, + }); + const eventFiber = yield* restarted.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* restarted.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "delayed-turn-complete-request", + deliveryId: "delayed-turn-complete-delivery", + type: "turn.complete", + threadId: "hermes-delayed-thread", + sourceMessageId: "hermes-user:hermes-original-turn", + outcome: "success", + }); + + const events = Array.from(yield* Fiber.join(eventFiber)); + NodeAssert.equal(events[0]?.type, "turn.completed"); + NodeAssert.equal(events[0]?.turnId, "hermes-original-turn"); + }), + ); + + it.effect("does not let a delayed completion close a newer active turn", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-delayed-newer-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const original = yield* adapter.sendTurn({ threadId, input: "first" }); + const newer = yield* adapter.sendTurn({ threadId, input: "second" }); + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "older-turn-complete-request", + deliveryId: "older-turn-complete-delivery", + type: "turn.complete", + threadId, + sourceMessageId: `hermes-user:${original.turnId}`, + outcome: "success", + }); + + const events = Array.from(yield* Fiber.join(eventFiber)); + NodeAssert.equal(events[0]?.turnId, original.turnId); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, newer.turnId); + }), + ); + + it.effect("attributes queued output to its source turn while a newer turn stays active", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-queued-attribution-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const original = yield* adapter.sendTurn({ threadId, input: "first" }); + const newer = yield* adapter.sendTurn({ threadId, input: "second" }); + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.type === "content.delta" || + event.type === "item.completed" || + event.type === "turn.completed", + ), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "queued-message-request", + deliveryId: "queued-message-delivery", + type: "message.send", + threadId, + sourceMessageId: `hermes-user:${original.turnId}`, + messageId: "queued-message", + content: "first answer", + final: true, + }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "queued-complete-request", + deliveryId: "queued-complete-delivery", + type: "turn.complete", + threadId, + sourceMessageId: `hermes-user:${original.turnId}`, + outcome: "success", + }); + + const events = Array.from(yield* Fiber.join(eventFiber)); + NodeAssert.deepEqual( + events.map((event) => event.turnId), + [original.turnId, original.turnId, original.turnId], + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, newer.turnId); + }), + ); + + it.effect("restores the still-running turn after an inline command completes", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-inline-command-state-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const original = yield* adapter.sendTurn({ threadId, input: "long task" }); + const command = yield* adapter.sendTurn({ threadId, input: "/status" }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "inline-command-complete-request", + deliveryId: "inline-command-complete-delivery", + type: "turn.complete", + threadId, + sourceMessageId: `hermes-user:${command.turnId}`, + outcome: "success", + }); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "running"); + NodeAssert.equal(session?.activeTurnId, original.turnId); + }), + ); + + it.effect("keeps source-less proactive output independent from an active turn", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-proactive-output-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const active = yield* adapter.sendTurn({ threadId, input: "foreground work" }); + const eventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "content.delta" || event.type === "item.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "proactive-message-request", + deliveryId: "proactive-message-delivery", + type: "message.send", + threadId, + messageId: "proactive-message", + content: "scheduled result", + final: true, + }); + + const events = Array.from(yield* Fiber.join(eventFiber)); + NodeAssert.deepEqual( + events.map((event) => event.turnId), + [undefined, undefined], + ); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.activeTurnId, active.turnId); + }), + ); + + it.effect("interrupts a session before Hermes has supplied a session key", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-interrupt-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + + yield* adapter.interruptTurn(threadId); + + NodeAssert.equal(sent.length, 1); + const request = sent[0]; + NodeAssert.equal(request?.type, "turn.interrupt"); + if (request?.type !== "turn.interrupt") return; + NodeAssert.equal(request.chatId, "t3agent"); + NodeAssert.equal(request.threadId, threadId); + NodeAssert.equal("sessionKey" in request, false); + }), + ); + + it.effect("completes an image-only assistant message with a persisted attachment", () => + Effect.gen(function* () { + const { adapter, sent } = yield* HermesAdapterTestHarness; + sent.length = 0; + const threadId = ThreadId.make("hermes-image-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const turn = yield* adapter.sendTurn({ threadId, input: "send an image" }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "image-request", + deliveryId: "image-delivery", + type: "message.send", + threadId, + messageId: "image-message", + content: "", + final: true, + images: [ + { + type: "image", + id: "hermes-image-1", + name: "tiny.png", + mimeType: "image/png", + sizeBytes: 8, + source: { type: "data-url", dataUrl: "data:image/png;base64,iVBORw0KGgo=" }, + }, + ], + }); + yield* adapter.receiveCallback({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "image-turn-complete-request", + deliveryId: "image-turn-complete-delivery", + type: "turn.complete", + threadId, + sourceMessageId: `hermes-user:${turn.turnId}`, + outcome: "success", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["turn.started", "item.completed", "turn.completed"], + ); + const completed = events.find((event) => event.type === "item.completed"); + NodeAssert.equal(completed?.type, "item.completed"); + if (completed?.type === "item.completed") { + NodeAssert.equal(completed.payload.detail, undefined); + const data = completed.payload.data as { + readonly attachments?: ReadonlyArray>; + }; + NodeAssert.equal(data.attachments?.length, 1); + NodeAssert.equal(data.attachments?.[0]?.type, "image"); + NodeAssert.equal(data.attachments?.[0]?.name, "tiny.png"); + NodeAssert.equal(data.attachments?.[0]?.mimeType, "image/png"); + NodeAssert.equal(data.attachments?.[0]?.sizeBytes, 8); + NodeAssert.match(String(data.attachments?.[0]?.id), /^hermes-image-thread-/); + } + }), + ); + + it.effect("does not commit a failed delivery before a safe retry succeeds", () => + Effect.gen(function* () { + const { adapter } = yield* HermesAdapterTestHarness; + const threadId = ThreadId.make("hermes-delivery-retry-thread"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("hermes"), + threadId, + runtimeMode: "full-access", + }); + const base = { + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "retry-request", + deliveryId: "retry-delivery", + type: "message.send" as const, + threadId, + messageId: "retry-message", + content: "image", + final: true, + }; + + const failedAsExpected = yield* adapter + .receiveCallback({ + ...base, + images: [ + { + type: "image", + id: "unsafe-image", + name: "unsafe.png", + mimeType: "image/png", + source: { type: "local-path", path: "/etc/passwd" }, + }, + ], + }) + .pipe( + Effect.as(false), + Effect.catch((error) => + Effect.sync(() => { + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); + return true; + }), + ), + ); + NodeAssert.equal(failedAsExpected, true); + + const accepted = yield* adapter.receiveCallback({ + ...base, + images: [ + { + type: "image", + id: "safe-image", + name: "safe.png", + mimeType: "image/png", + source: { type: "data-url", dataUrl: "data:image/png;base64,iVBORw0KGgo=" }, + }, + ], + }); + NodeAssert.deepEqual(accepted, { status: "accepted", deliveryId: "retry-delivery" }); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 00000000000..4b5e9be4dd8 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,988 @@ +import { + ApprovalRequestId, + EventId, + type HermesBridgeApprovalRequest, + HermesBridgeChatId, + type HermesBridgeChoice, + type HermesBridgeClarificationRequest, + type HermesBridgeHermesToT3Request, + HermesBridgeHermesToT3Request as HermesBridgeHermesToT3RequestSchema, + type HermesBridgeImageAttachment, + HermesBridgeImageAttachmentId, + HermesBridgeRequestId, + HermesBridgeSessionKey, + HermesBridgeThreadId, + HERMES_BRIDGE_PROTOCOL_VERSION, + MessageId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + TurnId, + type ChatAttachment, +} from "@t3tools/contracts"; +import * as NodeBuffer from "node:buffer"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { createAttachmentId, resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { parseBase64DataUrl } from "../../imageMime.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { ProviderAdapterShape, ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import type { HermesBridgeClient } from "../hermes/HermesBridgeClient.ts"; + +const PROVIDER = ProviderDriverKind.make("hermes"); +const RESUME_SCHEMA_VERSION = 1 as const; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const INTERACTIVE_REQUEST_PREFIX = "hermes:v1:"; +const USER_MESSAGE_TURN_PREFIX = "hermes-user:"; +const decodeCallback = Schema.decodeUnknownEffect(HermesBridgeHermesToT3RequestSchema); + +interface PendingApproval { + readonly kind: "approval"; + readonly sessionKey: HermesBridgeSessionKey; + readonly approvalId: string; + readonly providerRequestId: string; + readonly choices: ReadonlyArray; +} + +interface PendingClarification { + readonly kind: "clarification"; + readonly sessionKey: HermesBridgeSessionKey; + readonly clarifyId: string; + readonly providerRequestId: string; +} + +interface PendingConfirmation { + readonly kind: "confirmation"; + readonly sessionKey: HermesBridgeSessionKey; + readonly confirmId: string; + readonly choices: ReadonlyArray; +} + +type PendingRequest = PendingApproval | PendingClarification | PendingConfirmation; + +function interactiveRequestId(pending: PendingRequest): ApprovalRequestId { + return ApprovalRequestId.make( + `${INTERACTIVE_REQUEST_PREFIX}${NodeBuffer.Buffer.from(JSON.stringify(pending), "utf8").toString("base64url")}`, + ); +} + +function parseInteractiveRequestId(requestId: string): PendingRequest | undefined { + if (!requestId.startsWith(INTERACTIVE_REQUEST_PREFIX)) return undefined; + try { + const decoded: unknown = JSON.parse( + NodeBuffer.Buffer.from( + requestId.slice(INTERACTIVE_REQUEST_PREFIX.length), + "base64url", + ).toString("utf8"), + ); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) return undefined; + const value = decoded as Record; + if (typeof value.sessionKey !== "string") return undefined; + if (value.kind === "clarification") { + if (typeof value.clarifyId !== "string" || typeof value.providerRequestId !== "string") { + return undefined; + } + return { + kind: "clarification", + sessionKey: HermesBridgeSessionKey.make(value.sessionKey), + clarifyId: value.clarifyId, + providerRequestId: value.providerRequestId, + }; + } + if (value.kind !== "approval" && value.kind !== "confirmation") return undefined; + if (!Array.isArray(value.choices)) return undefined; + const choices = value.choices.flatMap((choice): Array => { + if (typeof choice !== "object" || choice === null || Array.isArray(choice)) return []; + const record = choice as Record; + if (typeof record.id !== "string" || typeof record.label !== "string") return []; + return [ + { + id: record.id, + label: record.label, + ...(typeof record.description === "string" ? { description: record.description } : {}), + }, + ]; + }); + if (choices.length !== value.choices.length) return undefined; + if (value.kind === "approval") { + if (typeof value.approvalId !== "string" || typeof value.providerRequestId !== "string") { + return undefined; + } + return { + kind: "approval", + sessionKey: HermesBridgeSessionKey.make(value.sessionKey), + approvalId: value.approvalId, + providerRequestId: value.providerRequestId, + choices, + }; + } + if (typeof value.confirmId !== "string") return undefined; + return { + kind: "confirmation", + sessionKey: HermesBridgeSessionKey.make(value.sessionKey), + confirmId: value.confirmId, + choices, + }; + } catch { + return undefined; + } +} + +interface HermesMessageState { + readonly text: string; + readonly itemId: RuntimeItemId; + readonly completed: boolean; +} + +interface HermesSessionContext { + session: ProviderSession; + sessionKey?: HermesBridgeSessionKey; + activeTurnId: TurnId | undefined; + readonly activeTurnIds: Array; + readonly messages: Map; + readonly pendingRequests: Map; + readonly seenDeliveries: Set; + readonly turns: Array<{ readonly id: TurnId; readonly items: Array }>; +} + +interface HermesResumeCursor { + readonly schemaVersion: typeof RESUME_SCHEMA_VERSION; + readonly chatId: string; + readonly threadId: string; + readonly sessionKey?: string; +} + +function parseResumeCursor(value: unknown): HermesResumeCursor | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const record = value as Record; + if ( + record.schemaVersion !== RESUME_SCHEMA_VERSION || + typeof record.chatId !== "string" || + typeof record.threadId !== "string" + ) { + return undefined; + } + return { + schemaVersion: RESUME_SCHEMA_VERSION, + chatId: record.chatId, + threadId: record.threadId, + ...(typeof record.sessionKey === "string" ? { sessionKey: record.sessionKey } : {}), + }; +} + +function resumeCursor(context: HermesSessionContext): HermesResumeCursor { + return { + schemaVersion: RESUME_SCHEMA_VERSION, + chatId: "t3agent", + threadId: context.session.threadId, + ...(context.sessionKey ? { sessionKey: context.sessionKey } : {}), + }; +} + +function destinationThreadId(callback: HermesBridgeHermesToT3Request): ThreadId | undefined { + if ("threadId" in callback && typeof callback.threadId === "string" && callback.threadId) { + return ThreadId.make(callback.threadId); + } + if ( + "chatId" in callback && + typeof callback.chatId === "string" && + callback.chatId && + callback.chatId !== "t3agent" + ) { + return ThreadId.make(callback.chatId); + } + return undefined; +} + +function sourceTurnId(sourceMessageId: string): TurnId | undefined { + if (!sourceMessageId.startsWith(USER_MESSAGE_TURN_PREFIX)) return undefined; + const value = sourceMessageId.slice(USER_MESSAGE_TURN_PREFIX.length); + return value ? TurnId.make(value) : undefined; +} + +function textDelta(previous: string, next: string): string { + if (next.startsWith(previous)) return next.slice(previous.length); + return ""; +} + +function firstAnswer(answers: ProviderUserInputAnswers): unknown { + return Object.values(answers)[0] ?? ""; +} + +function answerText(answer: unknown): string { + if (typeof answer === "string") return answer; + if (Array.isArray(answer)) return answer.map(String).join(", "); + if (answer === null || answer === undefined) return ""; + return String(answer); +} + +function approvalChoice( + decision: ProviderApprovalDecision, + choices: ReadonlyArray, +): string | undefined { + const allowed = new Set(choices.map((choice) => choice.id)); + switch (decision) { + case "accept": + return allowed.has("once") ? "once" : choices.find((choice) => choice.id !== "deny")?.id; + case "acceptForSession": + return allowed.has("session") + ? "session" + : allowed.has("always") + ? "always" + : allowed.has("once") + ? "once" + : undefined; + case "decline": + case "cancel": + return allowed.has("deny") ? "deny" : undefined; + } +} + +function selectedChoice( + answer: unknown, + choices: ReadonlyArray, +): HermesBridgeChoice | undefined { + const text = answerText(answer).trim(); + return choices.find((choice) => choice.id === text || choice.label === text); +} + +function confirmationChoice(decision: ProviderApprovalDecision): "once" | "always" | "cancel" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + return "cancel"; + } +} + +export interface HermesAdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly client: HermesBridgeClient; +} + +export interface HermesAdapter extends ProviderAdapterShape { + readonly receiveCallback: (payload: unknown) => Effect.Effect; +} + +export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* ( + options: HermesAdapterOptions, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const serverConfig = yield* ServerConfig; + const events = yield* PubSub.unbounded(); + const callbackSemaphore = yield* Semaphore.make(1); + const sessions = new Map(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const publish = (event: ProviderRuntimeEvent) => + PubSub.publish(events, event).pipe(Effect.asVoid); + const eventBase = (callback: HermesBridgeHermesToT3Request, threadId: ThreadId, suffix: string) => + nowIso.pipe( + Effect.map((createdAt) => { + const correlatedTurnId = + "sourceMessageId" in callback && typeof callback.sourceMessageId === "string" + ? sourceTurnId(callback.sourceMessageId) + : undefined; + const turnId = correlatedTurnId; + return { + eventId: EventId.make(`hermes:${callback.deliveryId}:${suffix}`), + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt, + ...(turnId ? { turnId } : {}), + }; + }), + ); + + const requestError = (method: string, detail: string, cause?: unknown) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail, + ...(cause ? { cause } : {}), + }); + + const persistImage = Effect.fn("HermesAdapter.persistImage")(function* ( + threadId: ThreadId, + image: HermesBridgeImageAttachment, + ) { + let bytes: Uint8Array; + if (image.source.type === "data-url") { + const parsed = parseBase64DataUrl(image.source.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* requestError("image.persist", `Invalid image payload for ${image.name}.`); + } + bytes = Buffer.from(parsed.base64, "base64"); + } else { + return yield* requestError( + "image.persist", + "Only byte-backed data URL images are accepted from Hermes callbacks.", + ); + } + if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) { + return yield* requestError("image.persist", `Image ${image.name} is empty or too large.`); + } + const id = createAttachmentId(threadId); + if (!id) return yield* requestError("image.persist", "Unable to create attachment id."); + const attachment: ChatAttachment = { + type: "image", + id, + name: image.name, + mimeType: image.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + const target = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!target) return yield* requestError("image.persist", "Unsafe attachment path."); + yield* fileSystem.makeDirectory(path.dirname(target), { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFile(target, bytes)), + Effect.mapError((cause) => + requestError("image.persist", `Unable to store ${image.name}.`, cause), + ), + ); + return attachment; + }); + + const persistImages = ( + threadId: ThreadId, + images: ReadonlyArray | undefined, + ): Effect.Effect, ProviderAdapterRequestError> => + Effect.forEach(images ?? [], (image) => persistImage(threadId, image), { concurrency: 1 }); + + const ensureContext = Effect.fn("HermesAdapter.ensureContext")(function* ( + threadId: ThreadId, + ): Effect.fn.Return { + const existing = sessions.get(threadId); + if (existing) return existing; + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: "full-access", + threadId, + createdAt, + updatedAt: createdAt, + }; + const context: HermesSessionContext = { + session, + activeTurnId: undefined, + activeTurnIds: [], + messages: new Map(), + pendingRequests: new Map(), + seenDeliveries: new Set(), + turns: [], + }; + sessions.set(threadId, context); + return context; + }); + + const handleMessageCallback = Effect.fn("HermesAdapter.handleMessageCallback")(function* ( + callback: Extract, + threadId: ThreadId, + context: HermesSessionContext, + ) { + const previous = context.messages.get(callback.messageId); + if (previous?.completed) return; + const itemId = previous?.itemId ?? RuntimeItemId.make(`hermes-message:${callback.messageId}`); + const delta = textDelta(previous?.text ?? "", callback.content); + if (delta.length > 0) { + const base = yield* eventBase(callback, threadId, "content"); + yield* publish({ + ...base, + type: "content.delta", + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + } + const attachments = callback.final + ? yield* persistImages(threadId, callback.images) + : ([] as ReadonlyArray); + context.messages.set(callback.messageId, { + text: callback.content, + itemId, + completed: callback.final, + }); + if (!callback.final) return; + const base = yield* eventBase(callback, threadId, "complete"); + yield* publish({ + ...base, + type: "item.completed", + itemId, + payload: { + itemType: "assistant_message", + status: "completed", + ...(callback.content.trim() ? { detail: callback.content } : {}), + data: { + finalText: callback.content, + ...(attachments.length > 0 ? { attachments } : {}), + }, + }, + }); + }); + + const rememberApproval = Effect.fn("HermesAdapter.rememberApproval")(function* ( + callback: HermesBridgeApprovalRequest, + threadId: ThreadId, + context: HermesSessionContext, + ) { + context.sessionKey = callback.sessionKey; + const pending: PendingApproval = { + kind: "approval", + sessionKey: callback.sessionKey, + approvalId: callback.approvalId, + providerRequestId: callback.providerRequestId, + choices: callback.choices, + }; + const requestId = RuntimeRequestId.make(interactiveRequestId(pending)); + context.pendingRequests.set(requestId, pending); + const base = yield* eventBase(callback, threadId, "approval"); + yield* publish({ + ...base, + type: "user-input.requested", + requestId, + payload: { + questions: [ + { + id: callback.approvalId, + header: callback.title ?? "Hermes approval", + question: callback.message, + options: callback.choices.map((choice) => ({ + label: choice.label, + description: choice.description ?? choice.label, + })), + multiSelect: false, + }, + ], + }, + }); + }); + + const rememberClarification = Effect.fn("HermesAdapter.rememberClarification")(function* ( + callback: HermesBridgeClarificationRequest, + threadId: ThreadId, + context: HermesSessionContext, + ) { + context.sessionKey = callback.sessionKey; + const pending: PendingClarification = { + kind: "clarification", + sessionKey: callback.sessionKey, + clarifyId: callback.clarifyId, + providerRequestId: callback.providerRequestId, + }; + const requestId = RuntimeRequestId.make(interactiveRequestId(pending)); + context.pendingRequests.set(requestId, pending); + const base = yield* eventBase(callback, threadId, "clarification"); + yield* publish({ + ...base, + type: "user-input.requested", + requestId, + payload: { + questions: [ + { + id: callback.clarifyId, + header: "Hermes needs input", + question: callback.question, + options: callback.choices.map((choice) => ({ + label: choice.label, + description: choice.description ?? choice.label, + })), + multiSelect: false, + }, + ], + }, + }); + }); + + const receiveCallback = (payload: unknown) => + callbackSemaphore.withPermits(1)( + decodeCallback(payload).pipe( + Effect.mapError((cause) => + requestError("callback.decode", "Invalid Hermes callback payload.", cause), + ), + Effect.flatMap( + Effect.fn("HermesAdapter.processCallback")(function* (callback) { + if (callback.type === "thread.create") { + return callback; + } + const threadId = destinationThreadId(callback); + if (!threadId) { + return yield* requestError( + "callback.route", + `Callback ${callback.type} did not identify a T3 Agent thread.`, + ); + } + const context = yield* ensureContext(threadId); + if (context.seenDeliveries.has(callback.deliveryId)) { + return { status: "duplicate", deliveryId: callback.deliveryId }; + } + switch (callback.type) { + case "message.send": + case "message.edit": + yield* handleMessageCallback(callback, threadId, context); + break; + case "message.delete": + return yield* requestError( + "callback.message.delete", + "T3 Agent does not support deleting a projected Hermes message.", + ); + case "typing.set": { + const base = yield* eventBase(callback, threadId, "typing"); + yield* publish({ + ...base, + type: "session.state.changed", + payload: { state: callback.active ? "running" : "ready" }, + }); + break; + } + case "turn.complete": { + const completedTurnId = sourceTurnId(callback.sourceMessageId); + if (!completedTurnId) { + return yield* requestError( + "callback.turn.complete", + `Turn completion source ${callback.sourceMessageId} is not a T3 Agent turn.`, + ); + } + if (callback.outcome === "cancelled") { + const base = yield* eventBase(callback, threadId, "turn-aborted"); + yield* publish({ + ...base, + type: "turn.aborted", + turnId: completedTurnId, + payload: { reason: "Hermes turn was cancelled" }, + }); + } else { + const base = yield* eventBase(callback, threadId, "turn-completed"); + yield* publish({ + ...base, + type: "turn.completed", + turnId: completedTurnId, + payload: + callback.outcome === "success" + ? { state: "completed" } + : { state: "failed", errorMessage: "Hermes turn failed" }, + }); + } + const completedIndex = context.activeTurnIds.indexOf(completedTurnId); + if (completedIndex >= 0) context.activeTurnIds.splice(completedIndex, 1); + const remainingTurnId = context.activeTurnIds.at(-1); + const updatedAt = yield* nowIso; + context.activeTurnId = remainingTurnId; + const { activeTurnId: _activeTurnId, ...settledSession } = context.session; + context.session = { + ...settledSession, + status: remainingTurnId + ? "running" + : callback.outcome === "failure" + ? "error" + : "ready", + ...(remainingTurnId ? { activeTurnId: remainingTurnId } : {}), + updatedAt, + resumeCursor: resumeCursor(context), + }; + break; + } + case "approval.request": + yield* rememberApproval(callback, threadId, context); + break; + case "clarification.request": + yield* rememberClarification(callback, threadId, context); + break; + case "slash-confirmation.request": { + context.sessionKey = callback.sessionKey; + const pending: PendingConfirmation = { + kind: "confirmation", + sessionKey: callback.sessionKey, + confirmId: callback.confirmId, + choices: [ + { id: "once", label: "Run once" }, + { id: "always", label: "Always allow" }, + { id: "cancel", label: "Cancel" }, + ], + }; + const requestId = RuntimeRequestId.make(interactiveRequestId(pending)); + context.pendingRequests.set(requestId, pending); + const base = yield* eventBase(callback, threadId, "confirmation"); + yield* publish({ + ...base, + type: "user-input.requested", + requestId, + payload: { + questions: [ + { + id: callback.confirmId, + header: callback.title, + question: callback.message, + options: [ + { label: "Run once", description: "Run this command once." }, + { label: "Always allow", description: "Remember this command." }, + { label: "Cancel", description: "Do not run this command." }, + ], + multiSelect: false, + }, + ], + }, + }); + break; + } + } + context.seenDeliveries.add(callback.deliveryId); + return { status: "accepted", deliveryId: callback.deliveryId }; + }), + ), + ), + ); + + const getContext = ( + threadId: ThreadId, + ): Effect.Effect => + Effect.suspend(() => { + const context = sessions.get(threadId); + return context + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }).pipe( + Effect.filterOrFail( + (context) => context.session.status !== "closed", + () => new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }), + ), + ); + + const startSession = Effect.fn("HermesAdapter.startSession")(function* ( + input: ProviderSessionStartInput, + ) { + const existing = sessions.get(input.threadId); + const createdAt = existing?.session.createdAt ?? (yield* nowIso); + const updatedAt = yield* nowIso; + const parsedResume = parseResumeCursor(input.resumeCursor); + const context: HermesSessionContext = existing ?? { + session: { + provider: PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + createdAt, + updatedAt, + }, + messages: new Map(), + activeTurnId: undefined, + activeTurnIds: [], + pendingRequests: new Map(), + seenDeliveries: new Set(), + turns: [], + }; + if (parsedResume?.sessionKey) { + context.sessionKey = HermesBridgeSessionKey.make(parsedResume.sessionKey); + } + context.session = { + ...context.session, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: undefined, + updatedAt, + resumeCursor: resumeCursor(context), + }; + sessions.set(input.threadId, context); + return context.session; + }); + + const sendTurn = Effect.fn("HermesAdapter.sendTurn")(function* (input: ProviderSendTurnInput) { + const context = yield* getContext(input.threadId); + const uuid = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + requestError("crypto/randomUUIDv4", "Unable to create a turn id.", cause), + ), + ); + const turnId = TurnId.make(`hermes-${uuid}`); + const createdAt = yield* nowIso; + context.activeTurnId = turnId; + context.activeTurnIds.push(turnId); + context.turns.push({ id: turnId, items: [] }); + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: createdAt, + }; + const images = yield* Effect.forEach(input.attachments ?? [], (attachment) => { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Unable to resolve attachment ${attachment.name}.`, + }), + ); + } + return Effect.succeed({ + type: "image" as const, + id: HermesBridgeImageAttachmentId.make(attachment.id), + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + source: { type: "local-path" as const, path: attachmentPath }, + }); + }); + yield* publish({ + eventId: EventId.make(`hermes:${turnId}:started`), + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId: input.threadId, + createdAt, + turnId, + type: "turn.started", + payload: {}, + }); + const requestId = HermesBridgeRequestId.make(`turn:${turnId}`); + yield* options.client + .send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId, + type: "message.submit", + messageId: MessageId.make(`hermes-user:${turnId}`), + chatId: HermesBridgeChatId.make("t3agent"), + threadId: HermesBridgeThreadId.make(input.threadId), + user: { id: "owner", name: "Owner" }, + content: input.input ?? "", + ...(images.length > 0 ? { images } : {}), + }) + .pipe( + Effect.tapError(() => + Effect.sync(() => { + const failedIndex = context.activeTurnIds.indexOf(turnId); + if (failedIndex >= 0) context.activeTurnIds.splice(failedIndex, 1); + const remainingTurnId = context.activeTurnIds.at(-1); + context.activeTurnId = remainingTurnId; + const { activeTurnId: _activeTurnId, ...failedSession } = context.session; + context.session = { + ...failedSession, + status: remainingTurnId ? "running" : "error", + ...(remainingTurnId ? { activeTurnId: remainingTurnId } : {}), + }; + }), + ), + ); + return { + threadId: input.threadId, + turnId, + resumeCursor: resumeCursor(context), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn = Effect.fn("HermesAdapter.interruptTurn")(function* (threadId: ThreadId) { + const context = yield* getContext(threadId); + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make( + `interrupt:${threadId}:${context.activeTurnId ?? "session"}`, + ), + type: "turn.interrupt", + ...(context.sessionKey ? { sessionKey: context.sessionKey } : {}), + chatId: HermesBridgeChatId.make("t3agent"), + threadId: HermesBridgeThreadId.make(threadId), + }); + const activeTurnIds = [...context.activeTurnIds]; + for (const activeTurnId of activeTurnIds) { + const createdAt = yield* nowIso; + yield* publish({ + eventId: EventId.make(`hermes:${activeTurnId}:interrupted`), + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + turnId: activeTurnId, + createdAt, + type: "turn.aborted", + payload: { reason: "Interrupted by user" }, + }); + } + context.activeTurnId = undefined; + context.activeTurnIds.length = 0; + const { activeTurnId: _activeTurnId, ...readySession } = context.session; + context.session = { + ...readySession, + status: "ready", + updatedAt: yield* nowIso, + }; + }); + + const respondToRequest = Effect.fn("HermesAdapter.respondToRequest")(function* ( + threadId: ThreadId, + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) { + const context = yield* ensureContext(threadId); + const pending = context.pendingRequests.get(requestId) ?? parseInteractiveRequestId(requestId); + if (!pending || pending.kind === "clarification") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: `Unknown Hermes approval request ${requestId}.`, + }); + } + if (pending.kind === "approval") { + const choice = approvalChoice(decision, pending.choices); + if (!choice) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: `Hermes approval ${requestId} does not offer that decision.`, + }); + } + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make(`approval-response:${requestId}`), + type: "approval.respond", + sessionKey: pending.sessionKey, + approvalId: pending.approvalId as never, + providerRequestId: pending.providerRequestId as never, + choice, + }); + } else { + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make(`confirmation-response:${requestId}`), + type: "slash-confirmation.respond", + sessionKey: pending.sessionKey, + confirmId: pending.confirmId as never, + choice: confirmationChoice(decision), + }); + } + context.pendingRequests.delete(requestId); + }); + + const respondToUserInput = Effect.fn("HermesAdapter.respondToUserInput")(function* ( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: ProviderUserInputAnswers, + ) { + const context = yield* ensureContext(threadId); + const pending = context.pendingRequests.get(requestId) ?? parseInteractiveRequestId(requestId); + if (!pending) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: `Unknown Hermes interactive request ${requestId}.`, + }); + } + const answer = firstAnswer(answers); + if (pending.kind === "clarification") { + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make(`clarification-response:${requestId}`), + type: "clarification.respond", + sessionKey: pending.sessionKey, + clarifyId: pending.clarifyId as never, + providerRequestId: pending.providerRequestId as never, + response: answerText(answer), + }); + } else { + const choice = selectedChoice(answer, pending.choices); + if (!choice) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: `Hermes request ${requestId} does not offer ${answerText(answer)}.`, + }); + } + if (pending.kind === "approval") { + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make(`approval-response:${requestId}`), + type: "approval.respond", + sessionKey: pending.sessionKey, + approvalId: pending.approvalId as never, + providerRequestId: pending.providerRequestId as never, + choice: choice.id, + }); + } else { + yield* options.client.send({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: HermesBridgeRequestId.make(`confirmation-response:${requestId}`), + type: "slash-confirmation.respond", + sessionKey: pending.sessionKey, + confirmId: pending.confirmId as never, + choice: choice.id as "once" | "always" | "cancel", + }); + } + } + context.pendingRequests.delete(requestId); + }); + + const stopSession = (threadId: ThreadId) => + interruptTurn(threadId).pipe( + Effect.catchTag("ProviderAdapterSessionNotFoundError", () => Effect.void), + Effect.andThen( + Effect.sync(() => { + const context = sessions.get(threadId); + if (context) context.session = { ...context.session, status: "closed" }; + }), + ), + ); + + const adapter: HermesAdapter = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions: () => Effect.succeed([...sessions.values()].map(({ session }) => session)), + hasSession: (threadId) => Effect.succeed(sessions.has(threadId)), + readThread: (threadId) => + getContext(threadId).pipe( + Effect.map((context): ProviderThreadSnapshot => ({ threadId, turns: [...context.turns] })), + ), + rollbackThread: (threadId) => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `Hermes conversation rollback is not supported for ${threadId}; use a Hermes slash command.`, + }), + ), + stopAll: () => Effect.forEach([...sessions.keys()], stopSession, { discard: true }), + streamEvents: Stream.fromPubSub(events), + receiveCallback, + }; + + return adapter; +}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5efbb6f1c14..c56749ff068 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1763,6 +1763,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "codex", "cursor", "grok", + "hermes", "opencode", ]); assert.strictEqual(cursorProvider?.enabled, false); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..58822279797 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesDriverEnv | OpenCodeDriverEnv; /** @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Response) { + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, response(request))), + ); + return { + client: makeHermesBridgeClient({ + baseUrl: " http://127.0.0.1:8789/// ", + token: "bridge-secret", + httpClient: HttpClient.make(execute), + }), + execute, + }; +} + +describe("HermesBridgeClient", () => { + it.effect("authenticates and decodes the capabilities snapshot", () => { + const { client, execute } = makeClient(() => + Response.json({ + protocolVersion: HERMES_BRIDGE_PROTOCOL_VERSION, + requestId: "provider-capabilities", + capabilities: { + asynchronousDelivery: true, + imageAttachments: true, + interrupts: true, + approvals: true, + clarifications: true, + slashConfirmations: true, + threadCreation: true, + commandCatalog: true, + }, + commands: [{ name: "restart", description: "Restart Hermes" }], + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + profile: "default", + }), + ); + + return Effect.gen(function* () { + const result = yield* client.getCapabilities; + + assert.strictEqual(result.model, "anthropic/claude-sonnet-4"); + assert.strictEqual(result.commands[0]?.name, "restart"); + assert.strictEqual(execute.mock.calls.length, 1); + + const request = execute.mock.calls[0]![0]; + const url = new URL(request.url); + assert.strictEqual(request.method, "GET"); + assert.strictEqual(url.origin, "http://127.0.0.1:8789"); + assert.strictEqual(url.pathname, "/v1/capabilities"); + assert.strictEqual(url.searchParams.get("protocolVersion"), "1"); + assert.strictEqual(url.searchParams.get("requestId"), "provider-capabilities"); + assert.strictEqual(request.headers.authorization, "Bearer bridge-secret"); + assert.strictEqual(request.headers.accept, "application/json"); + assert.strictEqual(request.headers["user-agent"], "t3-agent-hermes-bridge/1"); + }); + }); + + const requests = [ + [ + "/v1/messages", + decodeRequest({ + protocolVersion: 1, + requestId: "request-message", + type: "message.submit", + messageId: "message-1", + chatId: "t3agent", + threadId: "thread-1", + user: { id: "owner", name: "Owner" }, + content: "/restart", + }), + ], + [ + "/v1/interrupt", + decodeRequest({ + protocolVersion: 1, + requestId: "request-interrupt", + type: "turn.interrupt", + threadId: "thread-1", + }), + ], + [ + "/v1/approvals", + decodeRequest({ + protocolVersion: 1, + requestId: "request-approval", + type: "approval.respond", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + choice: "allow", + }), + ], + [ + "/v1/clarifications", + decodeRequest({ + protocolVersion: 1, + requestId: "request-clarification", + type: "clarification.respond", + sessionKey: "session-1", + clarifyId: "clarify-1", + providerRequestId: "provider-request-2", + response: "Use the safe default", + }), + ], + [ + "/v1/slash-confirmations", + decodeRequest({ + protocolVersion: 1, + requestId: "request-confirmation", + type: "slash-confirmation.respond", + sessionKey: "session-1", + confirmId: "confirm-1", + choice: "once", + }), + ], + ] as const; + + for (const [path, bridgeRequest] of requests) { + it.effect(`posts ${bridgeRequest.type} to ${path} with its idempotency key`, () => { + const { client, execute } = makeClient(() => + Response.json({ + protocolVersion: 1, + requestId: bridgeRequest.requestId, + deliveryId: "delivery-1", + status: "accepted", + }), + ); + + return Effect.gen(function* () { + const acknowledgement = yield* client.send(bridgeRequest); + + assert.strictEqual(acknowledgement.status, "accepted"); + const request = execute.mock.calls[0]![0]; + assert.strictEqual(request.method, "POST"); + assert.strictEqual(new URL(request.url).pathname, path); + assert.strictEqual(request.headers.authorization, "Bearer bridge-secret"); + assert.strictEqual(request.headers["idempotency-key"], bridgeRequest.requestId); + assert.strictEqual(request.headers["content-type"], "application/json"); + assert.deepStrictEqual(jsonBody(request), bridgeRequest); + }); + }); + } + + it.effect("maps non-success responses to a provider request error", () => { + const { client } = makeClient(() => Response.json({ error: "unavailable" }, { status: 503 })); + + return Effect.gen(function* () { + const error = yield* Effect.flip(client.getCapabilities); + + assert.instanceOf(error, ProviderAdapterRequestError); + assert.strictEqual(error.provider, "hermes"); + assert.strictEqual(error.method, "capabilities"); + assert.strictEqual(error.detail, "Hermes bridge request failed for capabilities."); + }); + }); + + it.effect("maps malformed acknowledgements to a provider request error", () => { + const request = requests[0]![1]; + const { client } = makeClient(() => + Response.json({ + protocolVersion: 1, + requestId: request.requestId, + status: "not-a-valid-status", + }), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip(client.send(request)); + + assert.instanceOf(error, ProviderAdapterRequestError); + assert.strictEqual(error.provider, "hermes"); + assert.strictEqual(error.method, "message.submit"); + }); + }); +}); diff --git a/apps/server/src/provider/hermes/HermesBridgeClient.ts b/apps/server/src/provider/hermes/HermesBridgeClient.ts new file mode 100644 index 00000000000..d631daf5887 --- /dev/null +++ b/apps/server/src/provider/hermes/HermesBridgeClient.ts @@ -0,0 +1,90 @@ +import { + HERMES_BRIDGE_PROTOCOL_VERSION, + HermesBridgeAcknowledgement, + HermesBridgeCapabilitiesResponse, + type HermesBridgeT3ToHermesRequest, +} from "@t3tools/contracts/hermesBridge"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; + +const PROVIDER = "hermes"; + +export interface HermesBridgeClient { + readonly getCapabilities: Effect.Effect< + HermesBridgeCapabilitiesResponse, + ProviderAdapterRequestError + >; + readonly send: ( + request: HermesBridgeT3ToHermesRequest, + ) => Effect.Effect; +} + +function requestPath(request: HermesBridgeT3ToHermesRequest): string { + switch (request.type) { + case "message.submit": + return "/v1/messages"; + case "turn.interrupt": + return "/v1/interrupt"; + case "approval.respond": + return "/v1/approvals"; + case "clarification.respond": + return "/v1/clarifications"; + case "slash-confirmation.respond": + return "/v1/slash-confirmations"; + } +} + +function normalizeBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +export function makeHermesBridgeClient(input: { + readonly baseUrl: string; + readonly token: string; + readonly httpClient: HttpClient.HttpClient; +}): HermesBridgeClient { + const baseUrl = normalizeBaseUrl(input.baseUrl); + const execute = input.httpClient.pipe(HttpClient.filterStatusOk); + + const authorize = (request: HttpClientRequest.HttpClientRequest) => + request.pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(input.token), + HttpClientRequest.setHeader("user-agent", "t3-agent-hermes-bridge/1"), + ); + + const mapRequestError = (method: string) => (cause: unknown) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Hermes bridge request failed for ${method}.`, + cause, + }); + + const capabilityQuery = new URLSearchParams({ + protocolVersion: String(HERMES_BRIDGE_PROTOCOL_VERSION), + requestId: "provider-capabilities", + }); + const getCapabilities = HttpClientRequest.get( + `${baseUrl}/v1/capabilities?${capabilityQuery.toString()}`, + ).pipe( + authorize, + execute.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(HermesBridgeCapabilitiesResponse)), + Effect.mapError(mapRequestError("capabilities")), + ); + + const send: HermesBridgeClient["send"] = (request) => + HttpClientRequest.post(`${baseUrl}${requestPath(request)}`).pipe( + authorize, + HttpClientRequest.setHeader("idempotency-key", request.requestId), + HttpClientRequest.bodyJsonUnsafe(request), + execute.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(HermesBridgeAcknowledgement)), + Effect.mapError(mapRequestError(request.type)), + ); + + return { getCapabilities, send }; +} diff --git a/apps/server/src/provider/hermes/HermesBridgeRegistry.test.ts b/apps/server/src/provider/hermes/HermesBridgeRegistry.test.ts new file mode 100644 index 00000000000..3796320f8cb --- /dev/null +++ b/apps/server/src/provider/hermes/HermesBridgeRegistry.test.ts @@ -0,0 +1,110 @@ +import { assert, describe, it, vi } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; +import { + HermesBridgeRegistryError, + receive, + register, + unregister, +} from "./HermesBridgeRegistry.ts"; + +describe("HermesBridgeRegistry", () => { + it.effect("propagates the authenticated payload and receiver result", () => { + const instanceId = ProviderInstanceId.make("hermes_registry_propagation"); + const payload = { type: "typing.set", deliveryId: "delivery-1" }; + const receiver = vi.fn((received: unknown) => Effect.succeed({ accepted: true, received })); + + return Effect.gen(function* () { + yield* register(instanceId, { token: "correct-token", receive: receiver }); + const result = yield* receive(instanceId, "correct-token", payload); + + assert.deepStrictEqual(result, { accepted: true, received: payload }); + assert.strictEqual(receiver.mock.calls.length, 1); + assert.strictEqual(receiver.mock.calls[0]![0], payload); + }).pipe(Effect.ensuring(unregister(instanceId))); + }); + + it.effect("rejects an incorrect token without invoking the receiver", () => { + const instanceId = ProviderInstanceId.make("hermes_registry_auth"); + const receiver = vi.fn(() => Effect.succeed("should-not-run")); + + return Effect.gen(function* () { + yield* register(instanceId, { token: "correct-token", receive: receiver }); + const result = yield* Effect.result(receive(instanceId, "incorrect-token", {})); + assert.isTrue(Result.isFailure(result)); + if (Result.isSuccess(result)) return; + const error = result.failure; + + assert.instanceOf(error, HermesBridgeRegistryError); + assert.strictEqual(error.operation, "authenticate"); + assert.strictEqual(receiver.mock.calls.length, 0); + }).pipe(Effect.ensuring(unregister(instanceId))); + }); + + it.effect("rejects an empty configured token", () => { + const instanceId = ProviderInstanceId.make("hermes_registry_empty_token"); + const receiver = vi.fn(() => Effect.succeed("should-not-run")); + + return Effect.gen(function* () { + yield* register(instanceId, { token: "", receive: receiver }); + const result = yield* Effect.result(receive(instanceId, "", {})); + assert.isTrue(Result.isFailure(result)); + if (Result.isSuccess(result)) return; + const error = result.failure; + + assert.strictEqual(error.operation, "authenticate"); + assert.strictEqual(receiver.mock.calls.length, 0); + }).pipe(Effect.ensuring(unregister(instanceId))); + }); + + it.effect("reports unknown instances and unregistered instances as lookup failures", () => { + const instanceId = ProviderInstanceId.make("hermes_registry_lifecycle"); + const receiver = vi.fn(() => Effect.succeed("received")); + + return Effect.gen(function* () { + const unknownResult = yield* Effect.result(receive(instanceId, "token", {})); + assert.isTrue(Result.isFailure(unknownResult)); + if (Result.isSuccess(unknownResult)) return; + const unknownError = unknownResult.failure; + assert.strictEqual(unknownError.operation, "lookup"); + + yield* register(instanceId, { token: "token", receive: receiver }); + assert.strictEqual(yield* receive(instanceId, "token", {}), "received"); + yield* unregister(instanceId); + + const unregisteredResult = yield* Effect.result(receive(instanceId, "token", {})); + assert.isTrue(Result.isFailure(unregisteredResult)); + if (Result.isSuccess(unregisteredResult)) return; + const unregisteredError = unregisteredResult.failure; + assert.strictEqual(unregisteredError.operation, "lookup"); + assert.strictEqual(receiver.mock.calls.length, 1); + }).pipe(Effect.ensuring(unregister(instanceId))); + }); + + it.effect("wraps receiver failures without exposing them as authentication errors", () => { + const instanceId = ProviderInstanceId.make("hermes_registry_receiver_error"); + const receiverError = new ProviderAdapterRequestError({ + provider: "hermes", + method: "message.send", + detail: "Callback was invalid.", + }); + + return Effect.gen(function* () { + yield* register(instanceId, { + token: "token", + receive: () => Effect.fail(receiverError), + }); + const result = yield* Effect.result(receive(instanceId, "token", {})); + assert.isTrue(Result.isFailure(result)); + if (Result.isSuccess(result)) return; + const error = result.failure; + + assert.instanceOf(error, HermesBridgeRegistryError); + assert.strictEqual(error.operation, "receive"); + assert.strictEqual(error.detail, receiverError.message); + }).pipe(Effect.ensuring(unregister(instanceId))); + }); +}); diff --git a/apps/server/src/provider/hermes/HermesBridgeRegistry.ts b/apps/server/src/provider/hermes/HermesBridgeRegistry.ts new file mode 100644 index 00000000000..96571570c27 --- /dev/null +++ b/apps/server/src/provider/hermes/HermesBridgeRegistry.ts @@ -0,0 +1,77 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { ProviderAdapterError } from "../Errors.ts"; + +export class HermesBridgeRegistryError extends Schema.TaggedErrorClass()( + "HermesBridgeRegistryError", + { + operation: Schema.Literals(["lookup", "authenticate", "receive"]), + instanceId: Schema.String, + detail: Schema.String, + }, +) {} + +export interface HermesBridgeReceiver { + readonly token: string; + readonly receive: (payload: unknown) => Effect.Effect; +} + +const receivers = new Map(); + +function constantTimeEqual(left: string, right: string): boolean { + const maximumLength = Math.max(left.length, right.length); + let difference = left.length ^ right.length; + for (let index = 0; index < maximumLength; index += 1) { + difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); + } + return difference === 0; +} + +export const register = (instanceId: ProviderInstanceId, receiver: HermesBridgeReceiver) => + Effect.sync(() => { + receivers.set(instanceId, receiver); + }); + +export const unregister = (instanceId: ProviderInstanceId) => + Effect.sync(() => { + receivers.delete(instanceId); + }); + +export const receive = ( + instanceId: ProviderInstanceId, + token: string, + payload: unknown, +): Effect.Effect => + Effect.suspend(() => { + const receiver = receivers.get(instanceId); + if (!receiver) { + return Effect.fail( + new HermesBridgeRegistryError({ + operation: "lookup", + instanceId, + detail: "Hermes provider instance is not registered.", + }), + ); + } + if (!receiver.token || !constantTimeEqual(receiver.token, token)) { + return Effect.fail( + new HermesBridgeRegistryError({ + operation: "authenticate", + instanceId, + detail: "Invalid bridge credential.", + }), + ); + } + return receiver.receive(payload).pipe( + Effect.mapError( + (cause) => + new HermesBridgeRegistryError({ + operation: "receive", + instanceId, + detail: cause.message, + }), + ), + ); + }); diff --git a/apps/server/src/provider/hermes/http.ts b/apps/server/src/provider/hermes/http.ts new file mode 100644 index 00000000000..fcd2d3e9d7d --- /dev/null +++ b/apps/server/src/provider/hermes/http.ts @@ -0,0 +1,176 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + HermesBridgeChatId, + HermesBridgeThreadCreateResponse, + HermesBridgeThreadId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { ServerConfig } from "../../config.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import * as HermesBridgeRegistry from "./HermesBridgeRegistry.ts"; + +const decodeInstanceId = Schema.decodeUnknownEffect(ProviderInstanceId); +const HERMES_CALLBACK_MAX_BODY_SIZE = FileSystem.MiB(16); +const isThreadCreateCallback = ( + value: unknown, +): value is { + readonly type: "thread.create"; + readonly protocolVersion: 1; + readonly requestId: string; + readonly deliveryId: string; + readonly parentChatId: string; + readonly name: string; +} => + typeof value === "object" && + value !== null && + "type" in value && + (value as { readonly type?: unknown }).type === "thread.create"; + +function bearerToken(request: HttpServerRequest.HttpServerRequest): string | undefined { + const authorization = request.headers.authorization; + if (!authorization) return undefined; + const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()); + return match?.[1]?.trim() || undefined; +} + +function stableIdSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, "") + .slice(0, 96); + return normalized || "delivery"; +} + +export const hermesBridgeHttpRouteLayer = Layer.unwrap( + Effect.gen(function* () { + const orchestration = yield* OrchestrationEngineService; + const config = yield* ServerConfig; + + return HttpRouter.add( + "POST", + "/api/hermes/:instanceId/events", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const params = yield* HttpRouter.params; + const rawInstanceId = params.instanceId; + const token = bearerToken(request); + if (!rawInstanceId || !token) { + return HttpServerResponse.jsonUnsafe({ error: "unauthorized" }, { status: 401 }); + } + const instanceId = yield* decodeInstanceId(rawInstanceId).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (!instanceId) { + return HttpServerResponse.jsonUnsafe({ error: "invalid_instance" }, { status: 400 }); + } + const payload = yield* request.json.pipe( + Effect.provideService(HttpServerRequest.MaxBodySize, HERMES_CALLBACK_MAX_BODY_SIZE), + Effect.orElseSucceed(() => undefined), + ); + if (payload === undefined) { + return HttpServerResponse.jsonUnsafe({ error: "invalid_json" }, { status: 400 }); + } + const result = yield* HermesBridgeRegistry.receive(instanceId, token, payload).pipe( + Effect.catch((error) => + Effect.succeed({ + _bridgeError: true as const, + status: + error.operation === "authenticate" || error.operation === "lookup" ? 401 : 400, + }), + ), + ); + if (typeof result === "object" && result !== null && "_bridgeError" in result) { + const status = + "status" in result && typeof result.status === "number" ? result.status : 400; + return HttpServerResponse.jsonUnsafe( + { error: status === 401 ? "unauthorized" : "invalid_callback" }, + { status }, + ); + } + if (isThreadCreateCallback(result)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const instanceSegment = stableIdSegment(instanceId); + const deliverySegment = stableIdSegment(result.deliveryId); + const projectId = ProjectId.make(`t3-agent-${instanceSegment}`); + const threadId = ThreadId.make(`hermes-${deliverySegment}`); + yield* orchestration.dispatch({ + type: "project.create", + commandId: CommandId.make(`hermes:${instanceId}:inbox-project`), + projectId, + title: "T3 Agent", + workspaceRoot: config.cwd, + defaultModelSelection: { instanceId, model: "active" }, + createdAt, + }); + yield* orchestration.dispatch({ + type: "thread.create", + commandId: CommandId.make(`hermes:${result.deliveryId}:thread-create`), + threadId, + projectId, + title: result.name, + modelSelection: { instanceId, model: "active" }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt, + }); + const response: HermesBridgeThreadCreateResponse = { + protocolVersion: result.protocolVersion, + requestId: result.requestId as never, + deliveryId: result.deliveryId as never, + chatId: HermesBridgeChatId.make("t3agent"), + threadId: HermesBridgeThreadId.make(threadId), + }; + return HttpServerResponse.jsonUnsafe(response, { status: 200 }); + } + return HttpServerResponse.jsonUnsafe( + { + protocolVersion: 1, + requestId: + typeof payload === "object" && + payload !== null && + "requestId" in payload && + typeof payload.requestId === "string" + ? payload.requestId + : "callback", + deliveryId: + typeof payload === "object" && + payload !== null && + "deliveryId" in payload && + typeof payload.deliveryId === "string" + ? payload.deliveryId + : undefined, + status: + typeof result === "object" && + result !== null && + "status" in result && + result.status === "duplicate" + ? "duplicate" + : "accepted", + }, + { status: 200 }, + ); + }).pipe( + Effect.catch((cause) => + Effect.logError("Hermes bridge callback failed", { cause }).pipe( + Effect.as(HttpServerResponse.jsonUnsafe({ error: "internal_error" }, { status: 500 })), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c8c4ff377e8..ae1c0fd9e88 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -84,6 +84,7 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; +import * as HermesBridgeRegistry from "./provider/hermes/HermesBridgeRegistry.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; @@ -7351,3 +7352,120 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); }); + +it.layer(NodeServices.layer)("Hermes callback route", (it) => { + it.effect("authenticates callbacks and echoes correlation identifiers", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const instanceId = ProviderInstanceId.make("hermes-test"); + const payload = { + protocolVersion: 1, + requestId: "callback-request-1", + deliveryId: "callback-delivery-1", + type: "message.send", + chatId: "t3agent", + threadId: "thread-1", + messageId: "message-1", + content: "done", + final: true, + }; + + yield* Effect.acquireUseRelease( + HermesBridgeRegistry.register(instanceId, { + token: "callback-secret", + receive: (value) => Effect.succeed(value), + }), + () => + Effect.gen(function* () { + const unauthorized = yield* fetchEffect(`/api/hermes/${instanceId}/events`, { + method: "POST", + headers: { + authorization: "Bearer wrong-secret", + "content-type": "application/json", + }, + body: jsonRequestBody(payload), + }); + assert.equal(unauthorized.status, 401); + + const response = yield* fetchEffect(`/api/hermes/${instanceId}/events`, { + method: "POST", + headers: { + authorization: "Bearer callback-secret", + "content-type": "application/json", + }, + body: jsonRequestBody(payload), + }); + assert.equal(response.status, 200); + assert.deepEqual(yield* responseJsonEffect(response), { + protocolVersion: 1, + requestId: "callback-request-1", + deliveryId: "callback-delivery-1", + status: "accepted", + }); + }), + () => HermesBridgeRegistry.unregister(instanceId), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("creates a deterministic T3 thread for proactive Hermes delivery", () => + Effect.gen(function* () { + const commands: Array = []; + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: commands.length }; + }), + }, + }, + }); + const instanceId = ProviderInstanceId.make("hermes-test"); + const payload = { + protocolVersion: 1, + requestId: "thread-request-1", + deliveryId: "cron-delivery-1", + type: "thread.create", + parentChatId: "t3agent", + name: "Morning brief", + }; + + yield* Effect.acquireUseRelease( + HermesBridgeRegistry.register(instanceId, { + token: "callback-secret", + receive: (value) => Effect.succeed(value), + }), + () => + Effect.gen(function* () { + const response = yield* fetchEffect(`/api/hermes/${instanceId}/events`, { + method: "POST", + headers: { + authorization: "Bearer callback-secret", + "content-type": "application/json", + }, + body: jsonRequestBody(payload), + }); + assert.equal(response.status, 200); + assert.deepEqual(yield* responseJsonEffect(response), { + protocolVersion: 1, + requestId: "thread-request-1", + deliveryId: "cron-delivery-1", + chatId: "t3agent", + threadId: "hermes-cron-delivery-1", + }); + assert.deepEqual( + commands.map((command) => command.type), + ["project.create", "thread.create"], + ); + assert.equal( + commands[1]?.type === "thread.create" ? commands[1].title : null, + "Morning brief", + ); + }), + () => HermesBridgeRegistry.unregister(instanceId), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0e6db87b109..b9008389d2b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { hermesBridgeHttpRouteLayer } from "./provider/hermes/http.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -359,6 +360,7 @@ export const makeRoutesLayer = Layer.mergeAll( assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, + hermesBridgeHttpRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ad303f607e7..c7ad27ae6d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4084,7 +4084,12 @@ function ChatViewContent(props: ChatViewProps) { composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0 - ? parseStandaloneComposerSlashCommand(trimmed) + ? parseStandaloneComposerSlashCommand( + trimmed, + providerStatuses.find( + (provider) => provider.instanceId === ctxSelectedModelSelection.instanceId, + )?.slashCommands ?? [], + ) : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 591a07ac4c5..b9b03d09c8b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -81,7 +81,10 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + mergeSlashCommandItemsWithProviderPrecedence, + searchSlashCommandItems, +} from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, getComposerProviderState, @@ -1008,7 +1011,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), ); const query = composerTrigger.query.trim().toLowerCase(); - const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; + const slashCommandItems = mergeSlashCommandItemsWithProviderPrecedence( + builtInSlashCommandItems, + providerSlashCommandItems, + ); if (!query) { return slashCommandItems; } diff --git a/apps/web/src/components/chat/ExpandedImagePreview.test.ts b/apps/web/src/components/chat/ExpandedImagePreview.test.ts new file mode 100644 index 00000000000..8cffa6223dc --- /dev/null +++ b/apps/web/src/components/chat/ExpandedImagePreview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildExpandedImagePreview } from "./ExpandedImagePreview"; + +describe("buildExpandedImagePreview", () => { + it("builds a preview carousel for the selected previewable assistant attachment", () => { + expect( + buildExpandedImagePreview( + [ + { id: "loading", name: "loading.png" }, + { id: "first", name: "first.png", previewUrl: "https://example.test/first.png" }, + { id: "second", name: "second.png", previewUrl: "https://example.test/second.png" }, + ], + "second", + ), + ).toEqual({ + images: [ + { src: "https://example.test/first.png", name: "first.png" }, + { src: "https://example.test/second.png", name: "second.png" }, + ], + index: 1, + }); + }); + + it("does not open a preview for an attachment whose asset URL is unavailable", () => { + expect( + buildExpandedImagePreview( + [ + { id: "loading", name: "loading.png" }, + { id: "ready", name: "ready.png", previewUrl: "https://example.test/ready.png" }, + ], + "loading", + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b340a248fbe..06b794cbd8f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -219,6 +219,55 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("renders assistant image attachments through the shared expandable image grid", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Preview generated-chart.png"'); + expect(markup).toContain('src="data:image/png;base64,aGVybWVz"'); + expect(markup).toContain('alt="generated-chart.png"'); + expect(markup).toContain("loading-preview.png"); + expect(markup).not.toContain('aria-label="Preview loading-preview.png"'); + expect(markup).not.toContain("(empty response)"); + }); + it("keeps assistant changed-files headers sticky below the thread header", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const assistantMessageId = MessageId.make("message-assistant-with-files"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f759aa150be..f9ef1ef9fc5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -887,39 +887,11 @@ function UserTimelineRow({ row }: { row: Extract
- {regularImages.length > 0 && ( -
- {regularImages.map((image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} + {previewAnnotations.map((annotation, index) => ( }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const assistantImages = row.message.attachments ?? []; + const messageText = + row.message.text || + (row.message.streaming || assistantImages.length > 0 ? "" : "(empty response)"); return ( <>
+ ; + className?: string; + onImageExpand: (preview: ExpandedImagePreview) => void; +}) { + if (props.images.length === 0) { + return null; + } + + return ( +
+ {props.images.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ ); +} + function AssistantCopyButton({ row }: { row: Extract }) { const assistantCopyState = resolveAssistantMessageCopyState({ text: row.message.text ?? null, diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts index bf7ab3e1d9a..02e65e49381 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.test.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; -import { searchSlashCommandItems } from "./composerSlashCommandSearch"; +import { + mergeSlashCommandItemsWithProviderPrecedence, + searchSlashCommandItems, +} from "./composerSlashCommandSearch"; describe("searchSlashCommandItems", () => { const claudeDriver = ProviderDriverKind.make("claudeAgent"); @@ -68,4 +71,81 @@ describe("searchSlashCommandItems", () => { "provider-slash-command:claudeAgent:gh-fix-ci", ]); }); + + it("gives advertised provider commands precedence over colliding built-ins", () => { + const builtInItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] satisfies Array>; + const providerItems = [ + { + id: "provider-slash-command:hermes:model", + type: "provider-slash-command", + provider: ProviderDriverKind.make("hermes"), + command: { name: "MODEL", description: "Change the Hermes model" }, + label: "/MODEL", + description: "Change the Hermes model", + }, + { + id: "provider-slash-command:hermes:plan", + type: "provider-slash-command", + provider: ProviderDriverKind.make("hermes"), + command: { name: "plan", description: "Run the Hermes plan command" }, + label: "/plan", + description: "Run the Hermes plan command", + }, + { + id: "provider-slash-command:hermes:default", + type: "provider-slash-command", + provider: ProviderDriverKind.make("hermes"), + command: { name: "default", description: "Run the Hermes default command" }, + label: "/default", + description: "Run the Hermes default command", + }, + { + id: "provider-slash-command:hermes:restart", + type: "provider-slash-command", + provider: ProviderDriverKind.make("hermes"), + command: { name: "restart", description: "Restart the Hermes session" }, + label: "/restart", + description: "Restart the Hermes session", + }, + ] satisfies Array>; + + expect( + mergeSlashCommandItemsWithProviderPrecedence(builtInItems, providerItems).map( + (item) => item.id, + ), + ).toEqual([ + "provider-slash-command:hermes:model", + "provider-slash-command:hermes:plan", + "provider-slash-command:hermes:default", + "provider-slash-command:hermes:restart", + ]); + expect( + searchSlashCommandItems( + mergeSlashCommandItemsWithProviderPrecedence(builtInItems, providerItems), + "model", + ).map((item) => item.id), + ).toEqual(["provider-slash-command:hermes:model"]); + }); }); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index c4919b19245..9523fdf8b43 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -4,12 +4,28 @@ import { scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { providerAdvertisesSlashCommand } from "../../composer-logic"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; -function scoreSlashCommandItem( - item: Extract, - query: string, -): number | null { +type SlashCommandItem = Extract< + ComposerCommandItem, + { type: "slash-command" | "provider-slash-command" } +>; + +export function mergeSlashCommandItemsWithProviderPrecedence( + builtInItems: ReadonlyArray>, + providerItems: ReadonlyArray>, +): SlashCommandItem[] { + const advertisedProviderCommands = providerItems.map(({ command }) => command); + return [ + ...builtInItems.filter( + (item) => !providerAdvertisesSlashCommand(advertisedProviderCommands, item.command), + ), + ...providerItems, + ]; +} + +function scoreSlashCommandItem(item: SlashCommandItem, query: string): number | null { const primaryValue = item.type === "slash-command" ? item.command.toLowerCase() : item.command.name.toLowerCase(); const description = item.description.toLowerCase(); @@ -43,18 +59,16 @@ function scoreSlashCommandItem( } export function searchSlashCommandItems( - items: ReadonlyArray< - Extract - >, + items: ReadonlyArray, query: string, -): Array> { +): SlashCommandItem[] { const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\/+/ }); if (!normalizedQuery) { return [...items]; } const ranked: Array<{ - item: Extract; + item: SlashCommandItem; score: number; tieBreaker: string; }> = []; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..ad7a7c3d225 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon, PiAgentIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("hermes")]: PiAgentIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb..bce477aa6ea 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -37,6 +37,17 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("exposes Hermes bridge credentials without exposing its enabled flag", () => { + const hermes = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("hermes")]; + + expect(hermes).toBeDefined(); + expect(deriveProviderSettingsFields(hermes!).map((field) => field.key)).toEqual([ + "bridgeUrl", + "ingressToken", + "callbackToken", + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..350782aa263 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + HermesSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, + PiAgentIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + icon: PiAgentIcon, + settingsSchema: HermesSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611..242b637ad2a 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -371,4 +371,15 @@ describe("parseStandaloneComposerSlashCommand", () => { it("ignores slash commands with extra message text", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); + + it("does not intercept built-in names advertised by the selected provider", () => { + const providerSlashCommands = [{ name: "plan" }, { name: "DEFAULT" }]; + + expect(parseStandaloneComposerSlashCommand("/plan", providerSlashCommands)).toBeNull(); + expect(parseStandaloneComposerSlashCommand("/default", providerSlashCommands)).toBeNull(); + }); + + it("continues handling non-colliding built-ins locally", () => { + expect(parseStandaloneComposerSlashCommand("/plan", [{ name: "restart" }])).toBe("plan"); + }); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 2d1d3aed3b1..f72ac32c3b4 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -264,16 +264,30 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos export function parseStandaloneComposerSlashCommand( text: string, + providerSlashCommands: ReadonlyArray<{ readonly name: string }> = [], ): Exclude | null { const match = /^\/(plan|default)\s*$/i.exec(text.trim()); if (!match) { return null; } const command = match[1]?.toLowerCase(); + if (!command || providerAdvertisesSlashCommand(providerSlashCommands, command)) { + return null; + } if (command === "plan") return "plan"; return "default"; } +export function providerAdvertisesSlashCommand( + providerSlashCommands: ReadonlyArray<{ readonly name: string }>, + commandName: string, +): boolean { + const normalizedCommandName = commandName.replace(/^\/+/, "").trim().toLowerCase(); + return providerSlashCommands.some( + (command) => command.name.trim().toLowerCase() === normalizedCommandName, + ); +} + export function replaceTextRange( text: string, rangeStart: number, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..20dd3a198b9 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -178,6 +178,35 @@ describe("derivePendingApprovals", () => { }); describe("derivePendingUserInputs", () => { + it("keeps freeform-only prompts that do not provide choices", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-freeform", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-freeform", + questions: [ + { + id: "details", + header: "Clarification", + question: "What should the agent do next?", + options: [], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)[0]?.questions[0]).toMatchObject({ + id: "details", + options: [], + }); + }); + it("tracks open structured prompts and removes resolved ones", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..16a85c16ba7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,7 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { value: ProviderDriverKind.make("hermes"), label: "Hermes", available: true }, ]; export type WorkLogToolLifecycleStatus = @@ -443,9 +444,6 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { - return null; - } return { id: question.id, header: question.header, diff --git a/integrations/hermes/t3agent/README.md b/integrations/hermes/t3agent/README.md new file mode 100644 index 00000000000..0792a459372 --- /dev/null +++ b/integrations/hermes/t3agent/README.md @@ -0,0 +1,134 @@ +# T3 Agent Hermes platform plugin + +This directory is the Hermes-native half of the T3 Agent gateway. Hermes stays +Hermes: it owns conversations, models, tools, cron jobs, approvals, and async +work. The T3 fork is a thread-native client and durable delivery surface. + +The plugin starts a bearer-authenticated HTTP server on loopback and translates +T3 requests into normal Hermes `MessageEvent` objects. In the other direction, +it posts a versioned tagged union to the T3 server. It does not invoke the +Hermes CLI or create a second agent runtime. + +## Install + +The plugin needs Hermes' `messaging` extra because both directions use +`aiohttp`. Copy or symlink this directory into the Hermes plugin directory: + +```bash +mkdir -p ~/.hermes/plugins +ln -s /absolute/path/to/t3agent/integrations/hermes/t3agent \ + ~/.hermes/plugins/t3agent-platform +hermes plugins enable t3agent-platform +``` + +Set four required values in `~/.hermes/.env` (generate the two tokens +independently with a cryptographically secure secret generator): + +```dotenv +T3_AGENT_INSTANCE_ID=hermes +T3_AGENT_BRIDGE_URL=http://127.0.0.1:3000 +T3_AGENT_INGRESS_TOKEN= +T3_AGENT_BRIDGE_TOKEN= +``` + +Hermes stores a bounded ingress idempotency ledger and a turn-completion +outbox under its state directory. Override their locations with +`T3_AGENT_INGRESS_LEDGER_PATH` and `T3_AGENT_OUTBOX_PATH` when the Hermes state +directory is not persistent. + +In T3 Agent settings, open the Hermes provider and set: + +- **Hermes bridge URL** to the plugin ingress (normally + `http://127.0.0.1:8789`). +- **Hermes ingress token** to `T3_AGENT_INGRESS_TOKEN`. +- **T3 callback token** to `T3_AGENT_BRIDGE_TOKEN`. + +`T3_AGENT_INSTANCE_ID` must match the T3 provider instance ID. The built-in +single-instance provider uses `hermes`; use another value only after creating a +matching explicit provider instance. + +The ingress defaults to `127.0.0.1:8789`. A non-loopback bind is rejected. If +T3 and Hermes run on different machines, keep this plugin loopback-only and put +an authenticated tunnel or a small local forwarder next to Hermes; do not bind +the raw ingress to a LAN or public interface. + +For cron delivery, configure a durable T3 thread: + +```dotenv +T3_AGENT_HOME_CHAT=t3agent +T3_AGENT_HOME_CHAT_THREAD_ID= +T3_AGENT_HOME_CHAT_NAME=Hermes Inbox +``` + +Then use `deliver=t3agent`. Hermes registers `T3_AGENT_HOME_CHAT` as the +platform's cron destination and, by its standard convention, reads the thread +from `T3_AGENT_HOME_CHAT_THREAD_ID`. The chat value is deliberately fixed to +`t3agent`, matching interactive messages so replies continue the Hermes session +seeded by the cron run. It uses the standalone sender when the cron process is +separate from the gateway process. A cron callback is an ordinary +`message.send` event with `final: true`. + +Restart the Hermes gateway after installing or changing configuration. Verify +the private ingress with: + +```bash +curl -H "Authorization: Bearer $T3_AGENT_INGRESS_TOKEN" \ + http://127.0.0.1:8789/v1/health +``` + +## Wire protocol + +Every request is authenticated exclusively with +`Authorization: Bearer `. Tokens never appear in JSON. Every frame has +`protocolVersion: 1` and `requestId`; callbacks also have `deliveryId` and a +`type` tag. Hermes sends callbacks to: + +```text +POST {T3_AGENT_BRIDGE_URL}/api/hermes/{T3_AGENT_INSTANCE_ID}/events +``` + +It also sends `Idempotency-Key: `. T3 should return a JSON object on +every 2xx response. Message callbacks may return `messageId`; `thread.create` +returns `threadId`. + +Ingress routes and tags: + +| Route | `type` | +| ------------------------------ | ---------------------------- | +| `POST /v1/messages` | `message.submit` | +| `POST /v1/interrupt` | `turn.interrupt` | +| `POST /v1/approvals` | `approval.respond` | +| `POST /v1/clarifications` | `clarification.respond` | +| `POST /v1/slash-confirmations` | `slash-confirmation.respond` | + +`GET /v1/health` and `GET /v1/capabilities` are bearer-authenticated too. The +capabilities response includes the initial command catalog; T3 can use it for +slash-command completion without reimplementing command behavior. + +Callback tags are `message.send`, `message.edit`, `message.delete`, +`typing.set`, `turn.complete`, `approval.request`, `clarification.request`, +`slash-confirmation.request`, and `thread.create`. Message send/edit content is +always cumulative full content. `final` closes an individual message bubble; +it does not imply that the whole agent turn is done. The plugin emits +`turn.complete` from Hermes' post-delivery lifecycle hook only after the final +response and attachments have been delivered. + +Request IDs are cached at ingress so retries do not inject duplicate turns or +resolve a prompt twice. Interactive T3 request IDs carry the resolver state +needed to answer an already-visible approval or clarification after a T3 server +restart. Outbound IDs prefer Hermes delivery metadata; when it does not provide +one, the plugin derives deterministic IDs from canonical event fields. The T3 +bridge treats `requestId` as its idempotency key. + +## Security and operations + +- Use independent high-entropy ingress and bridge tokens. +- Keep file permissions on `~/.hermes/.env` owner-only. +- Never put tokens in a bridge URL, logs, thread content, or JSON bodies. +- Rotate either token by updating both peers and restarting the gateway/T3 + server together. +- The plugin intentionally suppresses the aiohttp access log so bearer headers + cannot be exposed by custom access formats. +- T3 is the trusted upstream identity boundary. Once the bearer token is + accepted, Hermes trusts the `user` asserted by T3 and does not apply a second + platform-user allowlist. diff --git a/integrations/hermes/t3agent/adapter.py b/integrations/hermes/t3agent/adapter.py new file mode 100644 index 00000000000..8558b7d331f --- /dev/null +++ b/integrations/hermes/t3agent/adapter.py @@ -0,0 +1,1556 @@ +"""Hermes platform adapter for the T3 Agent surface. + +The adapter owns a loopback-only HTTP ingress used by the local T3 server and +posts all Hermes output to that server's authenticated event bridge. It is a +normal Hermes platform adapter: inbound messages become ``MessageEvent`` +instances and therefore use the gateway's regular sessions, commands, +interrupts, approvals, clarifications, and async-delivery machinery. +""" + +from __future__ import annotations + +import asyncio +import base64 +import binascii +from collections import OrderedDict +import hashlib +import hmac +import ipaddress +import json +import logging +import mimetypes +import os +from pathlib import Path +import secrets +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple +from urllib.parse import quote, unquote_to_bytes, urlparse + +try: + from aiohttp import ClientError, ClientSession, ClientTimeout, web + + AIOHTTP_AVAILABLE = True +except ImportError: # pragma: no cover - exercised by Hermes' requirement probe + ClientError = Exception # type: ignore[assignment] + ClientSession = None # type: ignore[assignment] + ClientTimeout = None # type: ignore[assignment] + web = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + ProcessingOutcome, + SendResult, + cache_image_from_url, + cache_media_bytes, +) +from gateway.session import build_session_key +from hermes_constants import get_hermes_dir + + +logger = logging.getLogger(__name__) + +PROTOCOL_VERSION = 1 +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8789 +DEFAULT_MAX_BODY_BYTES = 16 * 1_048_576 +MAX_IMAGE_BYTES = 10 * 1_048_576 +DEFAULT_TIMEOUT_SECONDS = 30.0 +DEFAULT_IDEMPOTENCY_CACHE_SIZE = 2_048 +CANONICAL_CHAT_ID = "t3agent" + +_INGRESS_TOKEN_ENV = "T3_AGENT_INGRESS_TOKEN" +_BRIDGE_TOKEN_ENV = "T3_AGENT_BRIDGE_TOKEN" +_BRIDGE_URL_ENV = "T3_AGENT_BRIDGE_URL" +_INSTANCE_ID_ENV = "T3_AGENT_INSTANCE_ID" +_HOME_CHAT_ENV = "T3_AGENT_HOME_CHAT" +_HOME_THREAD_ENV = "T3_AGENT_HOME_CHAT_THREAD_ID" +_OUTBOX_PATH_ENV = "T3_AGENT_OUTBOX_PATH" +_INGRESS_LEDGER_PATH_ENV = "T3_AGENT_INGRESS_LEDGER_PATH" + + +def _env_or_extra(config: PlatformConfig, env_name: str, key: str, default: Any = "") -> Any: + value = os.getenv(env_name) + if value is not None and value != "": + return value + return (getattr(config, "extra", {}) or {}).get(key, default) + + +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _positive_float(value: Any, default: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + +def _is_loopback_host(host: str) -> bool: + normalized = host.strip().strip("[]").lower() + if normalized == "localhost": + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _bridge_url_is_valid(url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username + + +def _canonical_id(prefix: str, value: Dict[str, Any]) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:32] + return f"{prefix}_{digest}" + + +def _metadata_value(metadata: Optional[Dict[str, Any]], *keys: str) -> Optional[str]: + if not isinstance(metadata, dict): + return None + for key in keys: + value = metadata.get(key) + if value is not None and str(value).strip(): + return str(value) + return None + + +def _require_string(payload: Dict[str, Any], key: str, *, allow_empty: bool = False) -> str: + value = payload.get(key) + if not isinstance(value, str) or (not allow_empty and not value.strip()): + raise ValueError(f"{key} must be a non-empty string") + return value + + +def _optional_string(payload: Dict[str, Any], key: str) -> Optional[str]: + value = payload.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} must be a non-empty string when provided") + return value + + +def _ack(request_id: str, status: str, message: Optional[str] = None) -> Dict[str, Any]: + body: Dict[str, Any] = { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "status": status, + } + if message: + body["message"] = message + return body + + +def _image_source_value(attachment: Dict[str, Any]) -> str: + if attachment.get("type") != "image": + raise ValueError("each images entry must have type image") + _require_string(attachment, "id") + _require_string(attachment, "name") + mime_type = _require_string(attachment, "mimeType") + if not mime_type.lower().startswith("image/"): + raise ValueError("image mimeType must start with image/") + source = attachment.get("source") + if not isinstance(source, dict): + raise ValueError("image source must be an object") + source_type = source.get("type") + key = {"local-path": "path", "url": "url", "data-url": "dataUrl"}.get(source_type) + if key is None: + raise ValueError("image source type must be local-path, url, or data-url") + source_value = _require_string(source, key) + if source_type != "data-url": + return source_value + + try: + header, encoded = source_value.split(",", 1) + if not header.lower().startswith("data:image/"): + raise ValueError("data-url image must use an image media type") + if ";base64" in header.lower(): + data = base64.b64decode(encoded, validate=True) + else: + data = unquote_to_bytes(encoded) + except (ValueError, binascii.Error) as exc: + raise ValueError("image dataUrl is invalid") from exc + cached = cache_media_bytes( + data, + filename=str(attachment["name"]), + mime_type=mime_type, + default_kind="image", + ) + if cached is None: + raise ValueError("image dataUrl did not contain a supported image") + return cached.path + + +def _local_image_attachment(path_value: str) -> Dict[str, Any]: + path = Path(path_value) + mime_type = mimetypes.guess_type(path.name)[0] or "" + if not mime_type.startswith("image/"): + raise ValueError(f"T3 Agent only supports image attachments: {path.name}") + try: + stat = path.stat() + if not path.is_file() or stat.st_size <= 0 or stat.st_size > MAX_IMAGE_BYTES: + raise ValueError( + f"Image attachment must be a regular file between 1 byte and {MAX_IMAGE_BYTES} bytes" + ) + data = path.read_bytes() + except OSError as exc: + raise ValueError(f"Unable to read image attachment: {path.name}") from exc + if not data or len(data) > MAX_IMAGE_BYTES: + raise ValueError(f"Image attachment must be between 1 byte and {MAX_IMAGE_BYTES} bytes") + attachment: Dict[str, Any] = { + "type": "image", + "id": _canonical_id("image", {"path": str(path), "sha256": hashlib.sha256(data).hexdigest()}), + "name": path.name or "image", + "mimeType": mime_type, + "sizeBytes": len(data), + "source": { + "type": "data-url", + "dataUrl": f"data:{mime_type};base64,{base64.b64encode(data).decode('ascii')}", + }, + } + return attachment + + +def _choice(value: Any, index: int) -> Dict[str, Any]: + if isinstance(value, dict): + choice_id = str(value.get("id") or value.get("value") or index).strip() + label = str(value.get("label") or value.get("text") or choice_id).strip() + result: Dict[str, Any] = {"id": choice_id, "label": label} + description = value.get("description") + if description is not None and str(description).strip(): + result["description"] = str(description) + return result + label = str(value).strip() + return {"id": label or str(index), "label": label or str(index)} + + +def _command_catalog() -> List[Dict[str, Any]]: + """Read Hermes' canonical gateway command registry, including plugins.""" + try: + from hermes_cli.commands import ( + COMMAND_REGISTRY, + _is_gateway_available, + _iter_plugin_command_entries, + _resolve_config_gates, + ) + + overrides = _resolve_config_gates() + commands: List[Dict[str, Any]] = [] + for command in COMMAND_REGISTRY: + if not _is_gateway_available(command, overrides): + continue + entry: Dict[str, Any] = { + "name": command.name, + "description": command.description, + } + if command.args_hint: + entry["inputHint"] = command.args_hint + if command.aliases: + entry["aliases"] = list(command.aliases) + commands.append(entry) + known = {entry["name"] for entry in commands} + for name, description, args_hint in _iter_plugin_command_entries(): + if name in known: + continue + entry = {"name": name, "description": description} + if args_hint: + entry["inputHint"] = args_hint + commands.append(entry) + try: + from agent.skill_commands import get_skill_commands + + for name, skill in sorted(get_skill_commands().items()): + normalized_name = str(name).lstrip("/") + if not normalized_name or normalized_name in known: + continue + description = str(skill.get("description") or "Run this Hermes skill") + commands.append({"name": normalized_name, "description": description}) + known.add(normalized_name) + except Exception: + logger.debug("[t3agent] could not read Hermes skill commands", exc_info=True) + return commands + except Exception: + logger.debug("[t3agent] could not read Hermes command registry", exc_info=True) + return [ + {"name": "new", "description": "Start a new Hermes conversation"}, + {"name": "stop", "description": "Interrupt the active Hermes turn"}, + {"name": "model", "description": "Show or change the conversation model"}, + {"name": "restart", "description": "Restart the Hermes gateway"}, + {"name": "help", "description": "Show Hermes gateway commands"}, + ] + + +def _runtime_identity() -> Dict[str, str]: + """Best-effort snapshot of the default Hermes provider/model/profile.""" + identity: Dict[str, str] = {} + try: + from hermes_cli.inventory import load_picker_context + + context = load_picker_context() + if context.current_provider: + identity["provider"] = str(context.current_provider) + if context.current_model: + identity["model"] = str(context.current_model) + except Exception: + logger.debug("[t3agent] could not read Hermes model identity", exc_info=True) + try: + from hermes_cli.profiles import get_active_profile_name + + profile = get_active_profile_name() + if profile: + identity["profile"] = str(profile) + except Exception: + logger.debug("[t3agent] could not read Hermes profile identity", exc_info=True) + return identity + + +class T3AgentAdapter(BasePlatformAdapter): + """Bridge a local T3 Agent server into Hermes as platform ``t3agent``.""" + + supports_async_delivery = True + supports_code_blocks = True + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("t3agent")) + self.instance_id = str(_env_or_extra(config, _INSTANCE_ID_ENV, "instance_id", "")).strip() + self.ingress_token = str(_env_or_extra(config, _INGRESS_TOKEN_ENV, "ingress_token", "")) + self.bridge_token = str(_env_or_extra(config, _BRIDGE_TOKEN_ENV, "bridge_token", "")) + self.bridge_url = str(_env_or_extra(config, _BRIDGE_URL_ENV, "bridge_url", "")).rstrip("/") + self.ingress_host = str( + _env_or_extra(config, "T3_AGENT_INGRESS_HOST", "ingress_host", DEFAULT_HOST) + ).strip() + self.ingress_port = _positive_int( + _env_or_extra(config, "T3_AGENT_INGRESS_PORT", "ingress_port", DEFAULT_PORT), + DEFAULT_PORT, + ) + self.max_body_bytes = _positive_int( + _env_or_extra( + config, "T3_AGENT_MAX_BODY_BYTES", "max_body_bytes", DEFAULT_MAX_BODY_BYTES + ), + DEFAULT_MAX_BODY_BYTES, + ) + self.timeout_seconds = _positive_float( + _env_or_extra( + config, + "T3_AGENT_BRIDGE_TIMEOUT_SECONDS", + "bridge_timeout_seconds", + DEFAULT_TIMEOUT_SECONDS, + ), + DEFAULT_TIMEOUT_SECONDS, + ) + self._runner: Optional[web.AppRunner] = None if AIOHTTP_AVAILABLE else None + self._site: Optional[web.TCPSite] = None if AIOHTTP_AVAILABLE else None + self._client: Optional[ClientSession] = None + self._bound_port: Optional[int] = None + self._idempotency_lock = asyncio.Lock() + self._completed_requests: "OrderedDict[str, Tuple[int, Dict[str, Any], str]]" = ( + OrderedDict() + ) + configured_ledger = str( + _env_or_extra(config, _INGRESS_LEDGER_PATH_ENV, "ingress_ledger_path", "") + ).strip() + if configured_ledger == ":memory:": + self._ingress_ledger_path: Optional[Path] = None + elif configured_ledger: + self._ingress_ledger_path = Path(configured_ledger) + else: + instance_hash = hashlib.sha256(self.instance_id.encode("utf-8")).hexdigest()[:16] + self._ingress_ledger_path = ( + get_hermes_dir("state", "state") / f"t3agent-ingress-{instance_hash}.json" + ) + self._load_ingress_ledger() + self._message_destinations: Dict[str, Dict[str, str]] = {} + self._processing_sources: Dict[Tuple[str, str], str] = {} + configured_outbox = str( + _env_or_extra(config, _OUTBOX_PATH_ENV, "outbox_path", "") + ).strip() + if configured_outbox == ":memory:": + self._completion_outbox_path: Optional[Path] = None + elif configured_outbox: + self._completion_outbox_path = Path(configured_outbox) + else: + instance_hash = hashlib.sha256(self.instance_id.encode("utf-8")).hexdigest()[:16] + self._completion_outbox_path = ( + get_hermes_dir("state", "state") / f"t3agent-completions-{instance_hash}.json" + ) + self._completion_outbox: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() + self._completion_outbox_lock = asyncio.Lock() + self._completion_outbox_wakeup = asyncio.Event() + self._completion_outbox_task: Optional[asyncio.Task] = None + self._completion_outbox_stopping = False + + @staticmethod + def _destination_key( + chat_id: str, metadata: Optional[Dict[str, Any]] = None + ) -> Tuple[str, str]: + thread_id = _metadata_value(metadata, "threadId", "thread_id") or "" + return str(chat_id), thread_id + + def _source_message_id( + self, + chat_id: str, + metadata: Optional[Dict[str, Any]] = None, + reply_to: Optional[str] = None, + ) -> Optional[str]: + explicit = _metadata_value( + metadata, + "sourceMessageId", + "source_message_id", + "reply_to_message_id", + ) + if explicit: + return explicit + # Busy-session bypass commands are handled inline while the original + # turn is still processing. Their reply anchor is therefore more + # specific than the destination-wide processing source. + if reply_to and str(reply_to).startswith("hermes-user:"): + return str(reply_to) + current = self._processing_sources.get(self._destination_key(chat_id, metadata)) + if current: + return current + return None + + async def on_processing_start(self, event: MessageEvent) -> None: + metadata = {"thread_id": getattr(event.source, "thread_id", None)} + self._processing_sources[self._destination_key(event.source.chat_id, metadata)] = str( + event.message_id + ) + + async def handle_message(self, event: MessageEvent) -> None: + """Complete Hermes' inline busy-session commands as real T3 turns.""" + if not self._message_handler: + return + session_key = build_session_key( + event.source, + group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), + ) + command = event.get_command() + from hermes_cli.commands import should_bypass_active_session + + if session_key not in self._active_sessions or not should_bypass_active_session(command): + await super().handle_message(event) + return + + outcome = ProcessingOutcome.SUCCESS + try: + if command in {"stop", "new", "reset"}: + self._discard_text_debounce(session_key) + await self._dispatch_active_session_command(event, session_key, command) + else: + response = await self._message_handler(event) + text, ephemeral_ttl = self._unwrap_ephemeral(response) + if text: + metadata = { + "thread_id": getattr(event.source, "thread_id", None), + "notify": True, + } + result = await self._send_with_retry( + chat_id=event.source.chat_id, + content=text, + reply_to=str(event.message_id), + metadata=metadata, + ) + if result is not None and not getattr(result, "success", False): + outcome = ProcessingOutcome.FAILURE + if ( + ephemeral_ttl > 0 + and result is not None + and getattr(result, "success", False) + and getattr(result, "message_id", None) + ): + self._schedule_ephemeral_delete( + chat_id=event.source.chat_id, + message_id=result.message_id, + ttl_seconds=ephemeral_ttl, + ) + except Exception: + logger.exception( + "[t3agent] busy command '/%s' dispatch failed", + command or "", + ) + outcome = ProcessingOutcome.FAILURE + await self.on_processing_complete(event, outcome) + + def _load_completion_outbox(self) -> None: + path = self._completion_outbox_path + if path is None or not path.exists(): + return + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + logger.warning("[t3agent] could not load completion outbox", exc_info=True) + return + if not isinstance(payload, list): + return + for fields in payload: + if not isinstance(fields, dict): + continue + if ( + isinstance(fields.get("chatId"), str) + and isinstance(fields.get("sourceMessageId"), str) + and fields.get("outcome") in {"success", "failure", "cancelled"} + ): + key = _canonical_id("completion", fields) + self._completion_outbox[key] = dict(fields) + + def _load_ingress_ledger(self) -> None: + path = self._ingress_ledger_path + if path is None or not path.exists(): + return + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + logger.warning("[t3agent] could not load ingress ledger", exc_info=True) + return + if not isinstance(payload, list): + return + for record in payload[-DEFAULT_IDEMPOTENCY_CACHE_SIZE:]: + if not isinstance(record, dict): + continue + request_id = record.get("requestId") + status = record.get("statusCode") + body = record.get("body") + state = record.get("state", "completed") + if isinstance(request_id, str) and isinstance(status, int) and isinstance(body, dict): + if state in {"pending", "completed", "failed"}: + self._completed_requests[request_id] = (status, dict(body), state) + + def _persist_ingress_ledger(self) -> None: + path = self._ingress_ledger_path + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + records = [ + { + "requestId": request_id, + "statusCode": status, + "body": body, + "state": state, + } + for request_id, (status, body, state) in self._completed_requests.items() + ] + temporary.write_text(json.dumps(records, separators=(",", ":")), encoding="utf-8") + temporary.chmod(0o600) + temporary.replace(path) + + def _persist_completion_outbox(self) -> None: + path = self._completion_outbox_path + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(list(self._completion_outbox.values()), separators=(",", ":")), + encoding="utf-8", + ) + temporary.chmod(0o600) + temporary.replace(path) + + def _ensure_completion_outbox_task(self) -> None: + if self._client is None or self._completion_outbox_stopping: + return + if self._completion_outbox_task is None or self._completion_outbox_task.done(): + self._completion_outbox_task = asyncio.create_task(self._completion_outbox_loop()) + + async def _enqueue_turn_completion(self, fields: Dict[str, Any]) -> None: + key = _canonical_id("completion", fields) + persist_error: Optional[OSError] = None + try: + async with self._completion_outbox_lock: + self._completion_outbox[key] = dict(fields) + self._persist_completion_outbox() + except OSError as exc: + persist_error = exc + finally: + # Even with degraded disk durability, keep the live delivery path + # running so a completed turn is not stranded in memory. + self._completion_outbox_wakeup.set() + self._ensure_completion_outbox_task() + if persist_error is not None: + raise persist_error + + async def _completion_outbox_loop(self) -> None: + backoff_seconds = 0.5 + while not self._completion_outbox_stopping: + async with self._completion_outbox_lock: + next_item = next(iter(self._completion_outbox.items()), None) + if next_item is None: + self._completion_outbox_wakeup.clear() + await self._completion_outbox_wakeup.wait() + continue + key, fields = next_item + ok, _, error = await self._post_event("turn.complete", fields) + if ok: + persisted = True + async with self._completion_outbox_lock: + if self._completion_outbox.get(key) == fields: + self._completion_outbox.pop(key, None) + try: + self._persist_completion_outbox() + except OSError: + # Keep retrying the same idempotent delivery until + # its durable removal can also be recorded. + self._completion_outbox[key] = fields + persisted = False + logger.warning( + "[t3agent] could not persist completion delivery", + exc_info=True, + ) + if not persisted: + await asyncio.sleep(backoff_seconds) + backoff_seconds = min(backoff_seconds * 2, 30.0) + continue + backoff_seconds = 0.5 + continue + logger.debug( + "[t3agent] completion outbox retry in %.1fs: %s", + backoff_seconds, + error or "unknown error", + ) + self._completion_outbox_wakeup.clear() + try: + await asyncio.wait_for( + self._completion_outbox_wakeup.wait(), timeout=backoff_seconds + ) + except asyncio.TimeoutError: + pass + backoff_seconds = min(backoff_seconds * 2, 30.0) + + @property + def authorization_is_upstream(self) -> bool: + # The T3 server is trusted only after constant-time bearer validation on + # a loopback transport. User IDs are assertions by that trusted server. + return True + + @property + def bound_port(self) -> Optional[int]: + return self._bound_port + + async def connect(self, *, is_reconnect: bool = False) -> bool: + if not AIOHTTP_AVAILABLE: + logger.error("[t3agent] aiohttp is required") + return False + if not validate_config(self.config): + logger.error("[t3agent] configuration is incomplete or unsafe") + return False + + app = web.Application(client_max_size=self.max_body_bytes) + app.router.add_get("/v1/health", self._health) + app.router.add_get("/v1/capabilities", self._capabilities) + app.router.add_post("/v1/messages", self._submit_message) + app.router.add_post("/v1/interrupt", self._interrupt_turn) + app.router.add_post("/v1/approvals", self._respond_approval) + app.router.add_post("/v1/clarifications", self._respond_clarification) + app.router.add_post("/v1/slash-confirmations", self._respond_slash_confirmation) + + self._client = ClientSession(timeout=ClientTimeout(total=self.timeout_seconds)) + self._runner = web.AppRunner(app, access_log=None) + try: + await self._runner.setup() + self._site = web.TCPSite(self._runner, self.ingress_host, self.ingress_port) + await self._site.start() + server = getattr(self._site, "_server", None) + sockets = getattr(server, "sockets", None) or [] + self._bound_port = int(sockets[0].getsockname()[1]) if sockets else self.ingress_port + except Exception: + logger.exception("[t3agent] failed to start loopback ingress") + await self.disconnect() + return False + + self._mark_connected() + self._completion_outbox_stopping = False + self._load_completion_outbox() + if self._completion_outbox: + self._completion_outbox_wakeup.set() + self._ensure_completion_outbox_task() + logger.info("[t3agent] ingress listening on %s:%s", self.ingress_host, self._bound_port) + return True + + async def disconnect(self) -> None: + self._completion_outbox_stopping = True + self._completion_outbox_wakeup.set() + if self._completion_outbox_task is not None: + self._completion_outbox_task.cancel() + try: + await self._completion_outbox_task + except asyncio.CancelledError: + pass + self._completion_outbox_task = None + if self._runner is not None: + try: + await self._runner.cleanup() + finally: + self._runner = None + self._site = None + self._bound_port = None + if self._client is not None: + await self._client.close() + self._client = None + self._mark_disconnected() + + def _authorized(self, request: web.Request) -> bool: + supplied = request.headers.get("Authorization", "") + prefix = "Bearer " + if not supplied.startswith(prefix): + return False + return hmac.compare_digest( + supplied[len(prefix) :].encode("utf-8"), self.ingress_token.encode("utf-8") + ) + + def _unauthorized(self) -> web.Response: + return web.json_response({"error": "unauthorized"}, status=401) + + def _discovery_identity( + self, request: web.Request + ) -> Tuple[Optional[str], Optional[web.Response]]: + if request.query.get("protocolVersion") != str(PROTOCOL_VERSION): + return None, web.json_response( + {"error": f"protocolVersion must be {PROTOCOL_VERSION}"}, status=400 + ) + request_id = request.query.get("requestId", "").strip() + if not request_id: + return None, web.json_response({"error": "requestId is required"}, status=400) + return request_id, None + + async def _read_frame(self, request: web.Request, expected_type: str) -> Dict[str, Any]: + try: + payload = await request.json() + except Exception as exc: + raise ValueError("request body must be a JSON object") from exc + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + if payload.get("protocolVersion") != PROTOCOL_VERSION: + raise ValueError(f"protocolVersion must be {PROTOCOL_VERSION}") + if payload.get("type") != expected_type: + raise ValueError(f"type must be {expected_type}") + _require_string(payload, "requestId") + return payload + + async def _run_once( + self, + request_id: str, + operation: Callable[[], Awaitable[Tuple[int, Dict[str, Any]]]], + *, + accepted_status: int = 200, + ) -> web.Response: + # Holding this lock across the short dispatch/resolver call also closes + # the race where two identical requests arrive before either is cached. + async with self._idempotency_lock: + cached = self._completed_requests.get(request_id) + if cached is not None: + self._completed_requests.move_to_end(request_id) + # A prior write may have failed while the process remained + # alive. Never acknowledge a duplicate until the ledger is + # durably synchronized. + self._persist_ingress_ledger() + if cached[2] == "pending": + failure = { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "error": ( + "Hermes restarted or failed after durably claiming this request; " + "execution cannot be replayed safely." + ), + } + self._completed_requests[request_id] = (409, failure, "failed") + self._persist_ingress_ledger() + return web.json_response(failure, status=409) + if cached[2] == "failed": + return web.json_response(cached[1], status=cached[0]) + duplicate = dict(cached[1]) + duplicate["status"] = "duplicate" + return web.json_response(duplicate, status=cached[0]) + # Write the idempotency claim before dispatch. If Hermes exits at + # any later point, a replay is suppressed rather than risking the + # same agent/tool side effects twice. Validation has already run, + # so this is the narrow durable inbox boundary. + pending_body = _ack(request_id, "accepted") + self._completed_requests[request_id] = ( + accepted_status, + pending_body, + "pending", + ) + try: + self._persist_ingress_ledger() + except OSError: + self._completed_requests.pop(request_id, None) + raise + try: + status, body = await operation() + except Exception: + failure = { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "error": "Hermes failed while executing the durably claimed request.", + } + self._completed_requests[request_id] = (500, failure, "failed") + self._persist_ingress_ledger() + raise + self._completed_requests[request_id] = (status, body, "completed") + self._completed_requests.move_to_end(request_id) + while len(self._completed_requests) > DEFAULT_IDEMPOTENCY_CACHE_SIZE: + self._completed_requests.popitem(last=False) + # Persist before returning the acknowledgement so a T3 retry after + # a Hermes restart cannot execute an already-completed operation. + self._persist_ingress_ledger() + return web.json_response(body, status=status) + + async def _health(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + request_id, error = self._discovery_identity(request) + if error is not None: + return error + return web.json_response( + { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "status": "healthy", + "instanceId": self.instance_id, + } + ) + + async def _capabilities(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + request_id, error = self._discovery_identity(request) + if error is not None: + return error + return web.json_response( + { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "capabilities": { + "asynchronousDelivery": True, + "imageAttachments": True, + "interrupts": True, + "approvals": True, + "clarifications": True, + "slashConfirmations": True, + "threadCreation": True, + "commandCatalog": True, + }, + "commands": _command_catalog(), + **_runtime_identity(), + } + ) + + async def _submit_message(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "message.submit") + request_id = _require_string(payload, "requestId") + message_id = _require_string(payload, "messageId") + chat_id = _require_string(payload, "chatId") + thread_id = _optional_string(payload, "threadId") + content = _require_string(payload, "content", allow_empty=True) + user = payload.get("user") + if not isinstance(user, dict): + raise ValueError("user must be an object") + user_id = _require_string(user, "id") + user_name = _require_string(user, "name") + images = payload.get("images", []) + if not isinstance(images, list) or not all(isinstance(item, dict) for item in images): + raise ValueError("images must be an array of image attachments") + media_urls = [_image_source_value(item) for item in images] + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def dispatch() -> Tuple[int, Dict[str, Any]]: + source = self.build_source( + chat_id=chat_id, + chat_name=thread_id or chat_id, + chat_type="thread" if thread_id else "dm", + user_id=user_id, + user_name=user_name, + thread_id=thread_id, + message_id=message_id, + ) + event = MessageEvent( + text=content, + message_type=MessageType.TEXT, + source=source, + raw_message=payload, + message_id=message_id, + media_urls=media_urls, + media_types=["image"] * len(images), + metadata={ + "requestId": request_id, + "threadId": thread_id, + "t3agent": True, + }, + ) + await self.handle_message(event) + return 202, _ack(request_id, "accepted") + + return await self._run_once(request_id, dispatch, accepted_status=202) + + async def _interrupt_turn(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "turn.interrupt") + request_id = _require_string(payload, "requestId") + session_key = _optional_string(payload, "sessionKey") + chat_id = _optional_string(payload, "chatId") + thread_id = _optional_string(payload, "threadId") + if session_key is None: + if chat_id is None: + raise ValueError("sessionKey or chatId must be provided") + source = self.build_source( + chat_id=chat_id, + chat_name=thread_id or chat_id, + chat_type="thread" if thread_id else "dm", + thread_id=thread_id, + ) + session_key = build_session_key( + source, + group_sessions_per_user=self.config.extra.get( + "group_sessions_per_user", True + ), + thread_sessions_per_user=self.config.extra.get( + "thread_sessions_per_user", False + ), + ) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def interrupt() -> Tuple[int, Dict[str, Any]]: + await self.cancel_session_processing(session_key) + return 200, _ack(request_id, "accepted") + + return await self._run_once(request_id, interrupt) + + async def _respond_approval(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "approval.respond") + request_id = _require_string(payload, "requestId") + session_key = _require_string(payload, "sessionKey") + _require_string(payload, "approvalId") + _require_string(payload, "providerRequestId") + choice = _require_string(payload, "choice") + if choice not in {"once", "session", "always", "deny"}: + raise ValueError("choice must be once, session, always, or deny") + reason = _optional_string(payload, "reason") + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def resolve() -> Tuple[int, Dict[str, Any]]: + from tools.approval import resolve_gateway_approval + + count = resolve_gateway_approval(session_key, choice, reason=reason) + return ( + (200, _ack(request_id, "accepted")) + if count + else (404, _ack(request_id, "rejected", "No pending approval matched")) + ) + + return await self._run_once(request_id, resolve) + + async def _respond_clarification(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "clarification.respond") + request_id = _require_string(payload, "requestId") + _require_string(payload, "sessionKey") + clarify_id = _require_string(payload, "clarifyId") + _require_string(payload, "providerRequestId") + response_value = payload.get("response") + if isinstance(response_value, str): + response = response_value + elif response_value is None: + response = "" + else: + response = json.dumps(response_value, ensure_ascii=False, sort_keys=True) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def resolve() -> Tuple[int, Dict[str, Any]]: + from tools.clarify_gateway import resolve_gateway_clarify + + found = resolve_gateway_clarify(clarify_id, response) + return ( + (200, _ack(request_id, "accepted")) + if found + else (404, _ack(request_id, "rejected", "No pending clarification matched")) + ) + + return await self._run_once(request_id, resolve) + + async def _respond_slash_confirmation(self, request: web.Request) -> web.Response: + if not self._authorized(request): + return self._unauthorized() + try: + payload = await self._read_frame(request, "slash-confirmation.respond") + request_id = _require_string(payload, "requestId") + session_key = _require_string(payload, "sessionKey") + confirm_id = _require_string(payload, "confirmId") + choice = _require_string(payload, "choice") + if choice not in {"once", "always", "cancel"}: + raise ValueError("choice must be once, always, or cancel") + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + + async def resolve() -> Tuple[int, Dict[str, Any]]: + from tools import slash_confirm + + result = await slash_confirm.resolve(session_key, confirm_id, choice) + found = result is not None + return ( + (200, _ack(request_id, "accepted")) + if found + else ( + 404, + _ack(request_id, "rejected", "No pending slash confirmation matched"), + ) + ) + + return await self._run_once(request_id, resolve) + + def _event_ids( + self, event_type: str, fields: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None + ) -> Tuple[str, str]: + delivery_id = _metadata_value(metadata, "deliveryId", "delivery_id", "obligation_id") + if delivery_id is None: + delivery_id = _canonical_id("delivery", {"type": event_type, **fields}) + request_id = _metadata_value(metadata, "requestId", "request_id") + if request_id is None: + request_id = _canonical_id( + "request", + { + "instanceId": self.instance_id, + "deliveryId": delivery_id, + "type": event_type, + }, + ) + return request_id, delivery_id + + async def _post_event( + self, + event_type: str, + fields: Dict[str, Any], + *, + metadata: Optional[Dict[str, Any]] = None, + client: Optional[ClientSession] = None, + ) -> Tuple[bool, Dict[str, Any], Optional[str]]: + request_id, delivery_id = self._event_ids(event_type, fields, metadata) + frame: Dict[str, Any] = { + "protocolVersion": PROTOCOL_VERSION, + "requestId": request_id, + "deliveryId": delivery_id, + "type": event_type, + **fields, + } + target = f"{self.bridge_url}/api/hermes/{quote(self.instance_id, safe='')}/events" + headers = { + "Authorization": f"Bearer {self.bridge_token}", + "Content-Type": "application/json", + "Idempotency-Key": request_id, + } + active_client = client or self._client + if active_client is None: + return False, {}, "T3 Agent bridge is not connected" + try: + async with active_client.post(target, json=frame, headers=headers) as response: + if not 200 <= response.status < 300: + return False, {}, f"T3 Agent bridge returned HTTP {response.status}" + try: + body = await response.json() + except Exception: + return False, {}, "T3 Agent bridge returned invalid JSON" + except asyncio.CancelledError: + raise + except (ClientError, asyncio.TimeoutError): + return False, {}, "T3 Agent bridge request failed" + if not isinstance(body, dict): + return False, {}, "T3 Agent bridge returned invalid JSON" + if body.get("protocolVersion") != PROTOCOL_VERSION: + return False, body, "T3 Agent bridge returned a mismatched protocol version" + if body.get("requestId") != request_id: + return False, body, "T3 Agent bridge returned a mismatched request ID" + if body.get("deliveryId") != delivery_id: + return False, body, "T3 Agent bridge returned a mismatched delivery ID" + status = body.get("status") + if status is not None and status not in {"accepted", "duplicate"}: + return False, body, "T3 Agent bridge rejected delivery" + return True, body, None + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + message_id = _metadata_value(metadata, "messageId", "message_id") or _canonical_id( + "message", {"chatId": chat_id, "replyTo": reply_to, "content": content} + ) + send_metadata = metadata or {} + turn_complete = bool( + send_metadata.get("turn_complete", send_metadata.get("notify", False)) + ) + fields: Dict[str, Any] = { + "chatId": str(chat_id), + "messageId": message_id, + "content": content, + # ``final`` closes this individual message bubble. Hermes sends + # commentary/tool progress without ``expect_edits``; those are + # complete messages, but they do not complete the whole turn. + "final": bool( + send_metadata.get("final") + if "final" in send_metadata + else turn_complete or not send_metadata.get("expect_edits", False) + ), + } + thread_id = _metadata_value(metadata, "threadId", "thread_id") + if thread_id: + fields["threadId"] = thread_id + source_message_id = self._source_message_id(chat_id, metadata, reply_to) + if source_message_id: + fields["sourceMessageId"] = source_message_id + self._message_destinations[message_id] = { + "chatId": str(chat_id), + **({"threadId": thread_id} if thread_id else {}), + **({"sourceMessageId": source_message_id} if source_message_id else {}), + } + images = (metadata or {}).get("images") + if isinstance(images, list) and images: + fields["images"] = images + ok, body, error = await self._post_event("message.send", fields, metadata=metadata) + return SendResult( + success=ok, + message_id=str(body.get("messageId") or message_id) if ok else None, + error=error, + raw_response=body if ok else None, + retryable=not ok, + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> SendResult: + """Deliver a Hermes-generated local image as an inline T3 attachment.""" + del kwargs + try: + attachment = _local_image_attachment(image_path) + except ValueError as exc: + return SendResult(success=False, error=str(exc), retryable=False) + send_metadata = dict(metadata or {}) + send_metadata["images"] = [attachment] + send_metadata["final"] = True + send_metadata["turn_complete"] = True + send_metadata.setdefault( + "messageId", + _canonical_id( + "message", + { + "chatId": chat_id, + "replyTo": reply_to, + "imageId": attachment["id"], + }, + ), + ) + return await self.send( + chat_id=chat_id, + content=caption or "", + reply_to=reply_to, + metadata=send_metadata, + ) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Fetch a safe remote image through Hermes' SSRF-guarded cache.""" + suffix = Path(urlparse(image_url).path).suffix.lower() + extension = suffix if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp"} else ".jpg" + try: + image_path = await cache_image_from_url(image_url, ext=extension) + except Exception as exc: + return SendResult(success=False, error=str(exc), retryable=False) + return await self.send_image_file( + chat_id=chat_id, + image_path=image_path, + caption=caption, + reply_to=reply_to, + metadata=metadata, + ) + + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + *, + finalize: bool = False, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + destination = self._message_destinations.get(str(message_id), {}) + thread_id = _metadata_value(metadata, "threadId", "thread_id") or destination.get( + "threadId" + ) + source_message_id = ( + self._source_message_id(chat_id, metadata) or destination.get("sourceMessageId") + ) + fields = { + "chatId": str(chat_id), + "messageId": str(message_id), + "content": content, + "final": bool(finalize), + } + if thread_id: + fields["threadId"] = thread_id + if source_message_id: + fields["sourceMessageId"] = source_message_id + ok, body, error = await self._post_event("message.edit", fields) + return SendResult( + success=ok, + message_id=str(body.get("messageId") or message_id) if ok else None, + error=error, + raw_response=body if ok else None, + retryable=not ok, + ) + + async def delete_message(self, chat_id: str, message_id: str) -> bool: + ok, _, _ = await self._post_event( + "message.delete", {"chatId": str(chat_id), "messageId": str(message_id)} + ) + return ok + + async def send_typing( + self, chat_id: str, metadata: Optional[Dict[str, Any]] = None + ) -> None: + fields: Dict[str, Any] = {"chatId": str(chat_id), "active": True} + thread_id = _metadata_value(metadata, "threadId", "thread_id") + if thread_id: + fields["threadId"] = thread_id + source_message_id = self._source_message_id(chat_id, metadata) + if source_message_id: + fields["sourceMessageId"] = source_message_id + await self._post_event("typing.set", fields, metadata=metadata) + + async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None: + """Signal the true end of a Hermes turn after all response delivery. + + Message ``finalize`` only closes a streaming bubble and is also used at + tool/segment boundaries. BasePlatformAdapter invokes this lifecycle + hook after the handler and every response/attachment send has finished, + making it the authoritative turn-completion seam. + """ + source = event.source + outcome_value = getattr(outcome, "value", str(outcome)).lower() + if outcome_value not in {"success", "failure", "cancelled"}: + outcome_value = "failure" + fields: Dict[str, Any] = { + "chatId": str(source.chat_id), + "sourceMessageId": str(event.message_id), + "outcome": outcome_value, + } + thread_id = getattr(source, "thread_id", None) + if thread_id: + fields["threadId"] = str(thread_id) + await self._enqueue_turn_completion(fields) + processing_key = self._destination_key( + source.chat_id, {"thread_id": thread_id} + ) + if self._processing_sources.get(processing_key) == str(event.message_id): + self._processing_sources.pop(processing_key, None) + + async def send_exec_approval( + self, + chat_id: str, + command: str, + session_key: str, + description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, + allow_permanent: bool = True, + smart_denied: bool = False, + ) -> SendResult: + approval_id = _metadata_value(metadata, "approvalId", "approval_id") or _canonical_id( + "approval", {"sessionKey": session_key, "command": command, "description": description} + ) + provider_request_id = _metadata_value( + metadata, "providerRequestId", "provider_request_id" + ) or approval_id + choices: List[Dict[str, Any]] = [{"id": "once", "label": "Allow once"}] + if not smart_denied: + choices.append({"id": "session", "label": "Allow for this session"}) + if allow_permanent: + choices.append({"id": "always", "label": "Always allow"}) + choices.append({"id": "deny", "label": "Deny"}) + fields: Dict[str, Any] = { + "chatId": str(chat_id), + "sessionKey": session_key, + "approvalId": approval_id, + "providerRequestId": provider_request_id, + "title": "Command approval required", + "message": f"{description}\n\n{command}", + "choices": choices, + } + thread_id = _metadata_value(metadata, "threadId", "thread_id") + if thread_id: + fields["threadId"] = thread_id + source_message_id = self._source_message_id(chat_id, metadata) + if source_message_id: + fields["sourceMessageId"] = source_message_id + ok, body, error = await self._post_event("approval.request", fields, metadata=metadata) + return SendResult( + success=ok, + message_id=str(body.get("messageId")) if ok and body.get("messageId") else None, + error=error, + raw_response=body if ok else None, + ) + + async def send_clarify( + self, + chat_id: str, + question: str, + choices: Optional[list], + clarify_id: str, + session_key: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + fields: Dict[str, Any] = { + "chatId": str(chat_id), + "sessionKey": session_key, + "clarifyId": clarify_id, + "providerRequestId": _metadata_value( + metadata, "providerRequestId", "provider_request_id" + ) + or clarify_id, + "question": question, + "choices": [_choice(choice, index) for index, choice in enumerate(choices or [])], + } + thread_id = _metadata_value(metadata, "threadId", "thread_id") + if thread_id: + fields["threadId"] = thread_id + source_message_id = self._source_message_id(chat_id, metadata) + if source_message_id: + fields["sourceMessageId"] = source_message_id + ok, body, error = await self._post_event( + "clarification.request", fields, metadata=metadata + ) + return SendResult( + success=ok, + message_id=str(body.get("messageId")) if ok and body.get("messageId") else None, + error=error, + raw_response=body if ok else None, + ) + + async def send_slash_confirm( + self, + chat_id: str, + title: str, + message: str, + session_key: str, + confirm_id: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + fields: Dict[str, Any] = { + "chatId": str(chat_id), + "sessionKey": session_key, + "confirmId": confirm_id, + "title": title, + "message": message, + } + thread_id = _metadata_value(metadata, "threadId", "thread_id") + if thread_id: + fields["threadId"] = thread_id + source_message_id = self._source_message_id(chat_id, metadata) + if source_message_id: + fields["sourceMessageId"] = source_message_id + ok, body, error = await self._post_event( + "slash-confirmation.request", fields, metadata=metadata + ) + return SendResult( + success=ok, + message_id=str(body.get("messageId")) if ok and body.get("messageId") else None, + error=error, + raw_response=body if ok else None, + ) + + async def create_handoff_thread(self, parent_chat_id: str, name: str) -> Optional[str]: + fields = { + "parentChatId": str(parent_chat_id), + "name": name, + "occurrenceId": secrets.token_hex(16), + } + body: Dict[str, Any] = {} + ok = False + for attempt in range(3): + ok, body, _ = await self._post_event("thread.create", fields) + if ok: + break + if attempt < 2: + await asyncio.sleep(0.25 * (2**attempt)) + if not ok or not body.get("threadId"): + return None + return str(body["threadId"]) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return {"name": str(chat_id), "type": "thread"} + + +async def _standalone_send( + pconfig: PlatformConfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[List[str]] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Deliver cron/proactive output without a live gateway process.""" + if not AIOHTTP_AVAILABLE: + return {"error": "T3 Agent standalone send requires aiohttp"} + adapter = T3AgentAdapter(pconfig) + if not validate_config(pconfig): + return {"error": "T3 Agent standalone send is not configured"} + if str(chat_id) != CANONICAL_CHAT_ID: + return {"error": f"T3 Agent chat ID must be {CANONICAL_CHAT_ID}"} + occurrence_id = secrets.token_hex(16) + message_id = _canonical_id( + "message", + { + "chatId": chat_id, + "threadId": thread_id, + "content": message, + "mediaFiles": media_files or [], + "occurrenceId": occurrence_id, + }, + ) + fields: Dict[str, Any] = { + "chatId": str(chat_id), + "messageId": message_id, + "content": message, + "final": True, + } + if thread_id: + fields["threadId"] = str(thread_id) + if media_files: + if force_document: + return {"error": "T3 Agent does not support document delivery"} + try: + fields["images"] = [_local_image_attachment(str(path)) for path in media_files] + except ValueError as exc: + return {"error": str(exc)} + timeout = ClientTimeout(total=adapter.timeout_seconds) + async with ClientSession(timeout=timeout) as client: + body: Dict[str, Any] = {} + error: Optional[str] = None + ok = False + for attempt in range(3): + ok, body, error = await adapter._post_event("message.send", fields, client=client) + if ok: + break + if attempt < 2: + await asyncio.sleep(0.25 * (2**attempt)) + if not ok: + return {"error": error or "T3 Agent standalone send failed"} + return {"success": True, "message_id": str(body.get("messageId") or message_id)} + + +def check_requirements() -> bool: + return AIOHTTP_AVAILABLE + + +def validate_config(config: PlatformConfig) -> bool: + instance_id = str(_env_or_extra(config, _INSTANCE_ID_ENV, "instance_id", "")).strip() + ingress_token = str(_env_or_extra(config, _INGRESS_TOKEN_ENV, "ingress_token", "")) + bridge_token = str(_env_or_extra(config, _BRIDGE_TOKEN_ENV, "bridge_token", "")) + bridge_url = str(_env_or_extra(config, _BRIDGE_URL_ENV, "bridge_url", "")).rstrip("/") + host = str(_env_or_extra(config, "T3_AGENT_INGRESS_HOST", "ingress_host", DEFAULT_HOST)) + home_chat = os.getenv(_HOME_CHAT_ENV, "").strip() + home_thread = os.getenv(_HOME_THREAD_ENV, "").strip() + return bool( + AIOHTTP_AVAILABLE + and instance_id + and ingress_token + and bridge_token + and _bridge_url_is_valid(bridge_url) + and _is_loopback_host(host) + and (not home_chat or (home_chat == CANONICAL_CHAT_ID and home_thread)) + ) + + +def is_connected(config: PlatformConfig) -> bool: + return validate_config(config) + + +def _env_enablement() -> Optional[dict]: + instance_id = os.getenv(_INSTANCE_ID_ENV, "").strip() + bridge_url = os.getenv(_BRIDGE_URL_ENV, "").strip() + ingress_token = os.getenv(_INGRESS_TOKEN_ENV, "") + bridge_token = os.getenv(_BRIDGE_TOKEN_ENV, "") + if not (instance_id and bridge_url and ingress_token and bridge_token): + return None + seed: Dict[str, Any] = { + "instance_id": instance_id, + "bridge_url": bridge_url, + "ingress_host": os.getenv("T3_AGENT_INGRESS_HOST", DEFAULT_HOST), + "ingress_port": _positive_int(os.getenv("T3_AGENT_INGRESS_PORT"), DEFAULT_PORT), + } + home_chat = os.getenv(_HOME_CHAT_ENV, "").strip() + home_thread = os.getenv(_HOME_THREAD_ENV, "").strip() + if home_chat: + seed["home_channel"] = { + "chat_id": home_chat, + "name": os.getenv("T3_AGENT_HOME_CHAT_NAME", "T3 Agent Home"), + } + if home_thread: + seed["home_channel"]["thread_id"] = home_thread + return seed + + +def register(ctx: Any) -> None: + ctx.register_platform( + name="t3agent", + label="T3 Agent", + adapter_factory=lambda cfg: T3AgentAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=[ + _INSTANCE_ID_ENV, + _BRIDGE_URL_ENV, + _INGRESS_TOKEN_ENV, + _BRIDGE_TOKEN_ENV, + ], + install_hint="Install Hermes with the messaging extra (aiohttp)", + env_enablement_fn=_env_enablement, + cron_deliver_env_var=_HOME_CHAT_ENV, + standalone_sender_fn=_standalone_send, + emoji="🪽", + pii_safe=True, + allow_update_command=True, + platform_hint=( + "You are chatting through T3 Agent, a thread-native Hermes surface. " + "Replies support Markdown and may arrive asynchronously in the same thread." + ), + ) diff --git a/integrations/hermes/t3agent/plugin.yaml b/integrations/hermes/t3agent/plugin.yaml new file mode 100644 index 00000000000..9e4bf85b6fd --- /dev/null +++ b/integrations/hermes/t3agent/plugin.yaml @@ -0,0 +1,64 @@ +name: t3agent-platform +label: T3 Agent +kind: platform +version: 0.1.0 +description: > + Thread-native T3 Agent surface for Hermes. Starts an authenticated, + loopback-only HTTP ingress and delivers live replies, background work, + approvals, clarifications, handoffs, and cron output to a self-hosted T3 + server bridge. +author: T3 Agent +requires_env: + - name: T3_AGENT_INSTANCE_ID + description: "Stable ID for this Hermes installation" + prompt: "T3 Agent instance ID" + password: false + - name: T3_AGENT_BRIDGE_URL + description: "T3 server origin, for example http://127.0.0.1:3000" + prompt: "T3 Agent bridge URL" + password: false + - name: T3_AGENT_INGRESS_TOKEN + description: "Bearer token the T3 server uses to call Hermes" + prompt: "T3 Agent ingress token" + password: true + - name: T3_AGENT_BRIDGE_TOKEN + description: "Bearer token Hermes uses to call the T3 server" + prompt: "T3 Agent bridge token" + password: true +optional_env: + - name: T3_AGENT_INGRESS_HOST + description: "Loopback ingress host (default: 127.0.0.1; non-loopback is rejected)" + prompt: "T3 Agent ingress host" + password: false + - name: T3_AGENT_INGRESS_PORT + description: "Loopback ingress port (default: 8789)" + prompt: "T3 Agent ingress port" + password: false + - name: T3_AGENT_HOME_CHAT + description: "Canonical Hermes chat namespace; set to t3agent for cron delivery" + prompt: "T3 Agent home chat" + password: false + - name: T3_AGENT_HOME_CHAT_THREAD_ID + description: "Existing T3 thread ID for home cron delivery" + prompt: "T3 Agent home thread ID" + password: false + - name: T3_AGENT_HOME_CHAT_NAME + description: "Display name for the default cron thread" + prompt: "T3 Agent home chat name" + password: false + - name: T3_AGENT_BRIDGE_TIMEOUT_SECONDS + description: "Outbound callback timeout in seconds (default: 30)" + prompt: "T3 Agent callback timeout" + password: false + - name: T3_AGENT_MAX_BODY_BYTES + description: "Maximum ingress JSON body size (default: 16777216)" + prompt: "T3 Agent maximum body bytes" + password: false + - name: T3_AGENT_INGRESS_LEDGER_PATH + description: "Durable ingress idempotency ledger path (defaults under Hermes state)" + prompt: "T3 Agent ingress ledger path" + password: false + - name: T3_AGENT_OUTBOX_PATH + description: "Durable turn-completion outbox path (defaults under Hermes state)" + prompt: "T3 Agent completion outbox path" + password: false diff --git a/integrations/hermes/t3agent/tests/test_adapter.py b/integrations/hermes/t3agent/tests/test_adapter.py new file mode 100644 index 00000000000..67b9eb4fe4b --- /dev/null +++ b/integrations/hermes/t3agent/tests/test_adapter.py @@ -0,0 +1,1078 @@ +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from typing import Any, Dict, List + +from aiohttp import ClientSession, web +from aiohttp.test_utils import TestClient, TestServer +from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig +from gateway.session import SessionSource, build_session_key +import pytest + +from integrations.hermes.t3agent import adapter as adapter_module + + +def make_config(**extra: Any) -> SimpleNamespace: + defaults = { + "instance_id": "hermes-test", + "ingress_token": "ingress-secret", + "bridge_token": "bridge-secret", + "bridge_url": "http://127.0.0.1:1", + "ingress_host": "127.0.0.1", + "ingress_port": 8789, + "outbox_path": ":memory:", + "ingress_ledger_path": ":memory:", + } + defaults.update(extra) + return SimpleNamespace(extra=defaults) + + +@pytest.fixture +def fake_platform(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + platform = SimpleNamespace(value="t3agent") + monkeypatch.setattr(adapter_module, "Platform", lambda _: platform) + return platform + + +async def make_ingress_client( + adapter: adapter_module.T3AgentAdapter, +) -> TestClient: + app = web.Application(client_max_size=adapter.max_body_bytes) + app.router.add_get("/v1/health", adapter._health) + app.router.add_get("/v1/capabilities", adapter._capabilities) + app.router.add_post("/v1/messages", adapter._submit_message) + app.router.add_post("/v1/interrupt", adapter._interrupt_turn) + app.router.add_post("/v1/approvals", adapter._respond_approval) + app.router.add_post("/v1/clarifications", adapter._respond_clarification) + app.router.add_post("/v1/slash-confirmations", adapter._respond_slash_confirmation) + client = TestClient(TestServer(app)) + await client.start_server() + return client + + +def auth_headers() -> Dict[str, str]: + return {"Authorization": "Bearer ingress-secret"} + + +async def wait_until(predicate: Any, *, timeout: float = 2.0) -> None: + async def poll() -> None: + while not predicate(): + await asyncio.sleep(0.01) + + await asyncio.wait_for(poll(), timeout=timeout) + + +@pytest.mark.asyncio +async def test_connect_binds_authenticated_loopback_ingress( + fake_platform: SimpleNamespace, + unused_tcp_port: int, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config(ingress_port=unused_tcp_port)) + assert await adapter.connect() is True + assert adapter.bound_port is not None + try: + async with ClientSession() as client: + response = await client.get( + f"http://127.0.0.1:{adapter.bound_port}/v1/health" + "?protocolVersion=1&requestId=health-connect", + headers=auth_headers(), + ) + assert response.status == 200 + assert (await response.json())["status"] == "healthy" + finally: + await adapter.disconnect() + + +def test_config_rejects_non_loopback_ingress(fake_platform: SimpleNamespace) -> None: + assert adapter_module.validate_config(make_config(ingress_host="0.0.0.0")) is False + + +@pytest.mark.asyncio +async def test_message_submit_builds_normal_event_and_is_idempotent( + fake_platform: SimpleNamespace, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config()) + events: List[Any] = [] + + async def capture(event: Any) -> None: + events.append(event) + + adapter.handle_message = capture # type: ignore[method-assign] + client = await make_ingress_client(adapter) + frame = { + "protocolVersion": 1, + "requestId": "request-1", + "type": "message.submit", + "messageId": "message-1", + "chatId": "chat-1", + "threadId": "thread-1", + "user": {"id": "user-1", "name": "Ada"}, + "content": "hello", + "images": [ + { + "type": "image", + "id": "image-1", + "name": "example.png", + "mimeType": "image/png", + "source": {"type": "local-path", "path": "/tmp/example.png"}, + } + ], + } + try: + first = await client.post("/v1/messages", headers=auth_headers(), json=frame) + duplicate = await client.post("/v1/messages", headers=auth_headers(), json=frame) + assert first.status == duplicate.status == 202 + assert (await first.json())["status"] == "accepted" + assert (await duplicate.json())["status"] == "duplicate" + assert len(events) == 1 + event = events[0] + assert event.text == "hello" + assert event.message_id == "message-1" + assert event.source.chat_id == "chat-1" + assert event.source.thread_id == "thread-1" + assert event.source.user_id == "user-1" + assert event.media_urls == ["/tmp/example.png"] + assert event.internal is False + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_ingress_idempotency_survives_adapter_restart( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + ledger_path = tmp_path / "ingress-ledger.json" + frame = { + "protocolVersion": 1, + "requestId": "durable-request-1", + "type": "message.submit", + "messageId": "durable-message-1", + "chatId": "t3agent", + "threadId": "thread-1", + "user": {"id": "owner", "name": "Owner"}, + "content": "run once", + } + first_events: List[Any] = [] + first = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(ledger_path)) + ) + + async def first_capture(event: Any) -> None: + first_events.append(event) + + first.handle_message = first_capture # type: ignore[method-assign] + first_client = await make_ingress_client(first) + try: + response = await first_client.post("/v1/messages", headers=auth_headers(), json=frame) + assert response.status == 202 + assert len(first_events) == 1 + assert json.loads(ledger_path.read_text(encoding="utf-8"))[0]["state"] == "completed" + finally: + await first_client.close() + + second_events: List[Any] = [] + second = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(ledger_path)) + ) + + async def second_capture(event: Any) -> None: + second_events.append(event) + + second.handle_message = second_capture # type: ignore[method-assign] + second_client = await make_ingress_client(second) + try: + replay = await second_client.post("/v1/messages", headers=auth_headers(), json=frame) + assert replay.status == 202 + assert (await replay.json())["status"] == "duplicate" + assert second_events == [] + finally: + await second_client.close() + + +@pytest.mark.asyncio +async def test_pending_ingress_claim_becomes_terminal_after_restart( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + ledger_path = tmp_path / "ingress-ledger.json" + ledger_path.write_text( + json.dumps( + [ + { + "requestId": "pending-request", + "statusCode": 202, + "body": { + "protocolVersion": 1, + "requestId": "pending-request", + "status": "accepted", + }, + "state": "pending", + } + ] + ), + encoding="utf-8", + ) + adapter = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(ledger_path)) + ) + events: List[Any] = [] + + async def capture(event: Any) -> None: + events.append(event) + + adapter.handle_message = capture # type: ignore[method-assign] + client = await make_ingress_client(adapter) + try: + response = await client.post( + "/v1/messages", + headers=auth_headers(), + json={ + "protocolVersion": 1, + "requestId": "pending-request", + "type": "message.submit", + "messageId": "pending-message", + "chatId": "t3agent", + "threadId": "thread-1", + "user": {"id": "owner", "name": "Owner"}, + "content": "must not replay", + }, + ) + assert response.status == 409 + assert "cannot be replayed safely" in (await response.json())["error"] + assert events == [] + assert json.loads(ledger_path.read_text(encoding="utf-8"))[0]["state"] == "failed" + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_failed_ingress_operation_is_terminal_across_restart( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + ledger_path = tmp_path / "ingress-ledger.json" + frame = { + "protocolVersion": 1, + "requestId": "failed-operation-request", + "type": "message.submit", + "messageId": "failed-operation-message", + "chatId": "t3agent", + "threadId": "thread-1", + "user": {"id": "owner", "name": "Owner"}, + "content": "fail once without replay", + } + first = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(ledger_path)) + ) + + async def fail(_: Any) -> None: + raise RuntimeError("dispatch failed") + + first.handle_message = fail # type: ignore[method-assign] + first_client = await make_ingress_client(first) + try: + response = await first_client.post("/v1/messages", headers=auth_headers(), json=frame) + assert response.status == 500 + assert json.loads(ledger_path.read_text(encoding="utf-8"))[0]["state"] == "failed" + finally: + await first_client.close() + + replayed: List[Any] = [] + second = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(ledger_path)) + ) + + async def capture(event: Any) -> None: + replayed.append(event) + + second.handle_message = capture # type: ignore[method-assign] + second_client = await make_ingress_client(second) + try: + retry = await second_client.post("/v1/messages", headers=auth_headers(), json=frame) + assert retry.status == 500 + assert "failed while executing" in (await retry.json())["error"] + assert replayed == [] + finally: + await second_client.close() + + +@pytest.mark.asyncio +async def test_ingress_fails_closed_when_ledger_cannot_be_persisted( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + blocked_parent = tmp_path / "not-a-directory" + blocked_parent.write_text("blocked", encoding="utf-8") + adapter = adapter_module.T3AgentAdapter( + make_config(ingress_ledger_path=str(blocked_parent / "ledger.json")) + ) + events: List[Any] = [] + + async def capture(event: Any) -> None: + events.append(event) + + adapter.handle_message = capture # type: ignore[method-assign] + client = await make_ingress_client(adapter) + try: + response = await client.post( + "/v1/messages", + headers=auth_headers(), + json={ + "protocolVersion": 1, + "requestId": "unpersisted-request", + "type": "message.submit", + "messageId": "unpersisted-message", + "chatId": "t3agent", + "threadId": "thread-1", + "user": {"id": "owner", "name": "Owner"}, + "content": "must not be durably acknowledged", + }, + ) + assert response.status == 500 + assert events == [] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_ingress_requires_bearer_and_exact_protocol( + fake_platform: SimpleNamespace, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config()) + client = await make_ingress_client(adapter) + try: + unauthorized = await client.get( + "/v1/health?protocolVersion=1&requestId=health-1" + ) + assert unauthorized.status == 401 + + health = await client.get( + "/v1/health?protocolVersion=1&requestId=health-1", headers=auth_headers() + ) + assert await health.json() == { + "protocolVersion": 1, + "requestId": "health-1", + "status": "healthy", + "instanceId": "hermes-test", + } + + capabilities = await client.get( + "/v1/capabilities?protocolVersion=1&requestId=provider-capabilities", + headers=auth_headers(), + ) + capabilities_body = await capabilities.json() + assert capabilities_body["requestId"] == "provider-capabilities" + assert capabilities_body["capabilities"]["asynchronousDelivery"] is True + assert capabilities_body["capabilities"]["commandCatalog"] is True + command_names = {command["name"] for command in capabilities_body["commands"]} + assert {"new", "restart", "model", "stop", "commands"} <= command_names + assert len(command_names) >= 40 + + bad_version = await client.post( + "/v1/messages", + headers=auth_headers(), + json={"protocolVersion": 2, "requestId": "r", "type": "message.submit"}, + ) + assert bad_version.status == 400 + assert "protocolVersion" in (await bad_version.json())["error"] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_interrupt_can_resolve_session_from_chat_and_thread( + fake_platform: SimpleNamespace, +) -> None: + adapter = adapter_module.T3AgentAdapter(make_config()) + interrupted: List[str] = [] + + async def capture(session_key: str, **_: Any) -> None: + interrupted.append(session_key) + + adapter.cancel_session_processing = capture # type: ignore[method-assign] + client = await make_ingress_client(adapter) + try: + response = await client.post( + "/v1/interrupt", + headers=auth_headers(), + json={ + "protocolVersion": 1, + "requestId": "interrupt-1", + "type": "turn.interrupt", + "chatId": "chat-1", + "threadId": "thread-1", + }, + ) + assert response.status == 200 + assert interrupted == ["agent:main:t3agent:thread:chat-1:thread-1"] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_send_posts_direct_tagged_union_with_stable_ids( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + headers: List[Dict[str, str]] = [] + + async def receive(request: web.Request) -> web.Response: + received.append(await request.json()) + headers.append(dict(request.headers)) + return web.json_response( + { + "protocolVersion": 1, + "requestId": "request-1", + "deliveryId": "delivery-1", + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + try: + first = await adapter.send( + "chat-1", + "full cumulative content", + metadata={ + "threadId": "thread-1", + "deliveryId": "delivery-1", + "requestId": "request-1", + "messageId": "hermes-message-1", + "final": False, + }, + ) + assert first.success is True + assert first.message_id == "hermes-message-1" + assert received == [ + { + "protocolVersion": 1, + "requestId": "request-1", + "deliveryId": "delivery-1", + "type": "message.send", + "chatId": "chat-1", + "threadId": "thread-1", + "messageId": "hermes-message-1", + "content": "full cumulative content", + "final": False, + } + ] + assert headers[0]["Authorization"] == "Bearer bridge-secret" + assert headers[0]["Idempotency-Key"] == "request-1" + finally: + await adapter._client.close() + adapter._client = None + await server.close() + + +@pytest.mark.asyncio +async def test_stream_metadata_marks_preview_then_routes_final_edit( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + try: + preview = await adapter.send( + "chat-1", + "partial", + metadata={"threadId": "thread-1", "expect_edits": True}, + ) + assert preview.success is True + assert preview.message_id is not None + + final = await adapter.edit_message( + "chat-1", + preview.message_id, + "partial answer", + finalize=True, + ) + assert final.success is True + assert received[0]["type"] == "message.send" + assert received[0]["final"] is False + assert received[0]["threadId"] == "thread-1" + assert received[1]["type"] == "message.edit" + assert received[1]["final"] is True + assert received[1]["threadId"] == "thread-1" + + await adapter.on_processing_complete( + SimpleNamespace( + source=SimpleNamespace(chat_id="chat-1", thread_id="thread-1"), + message_id="hermes-user:hermes-turn-1", + ), + SimpleNamespace(value="success"), + ) + await wait_until(lambda: len(received) >= 3) + assert received[2]["type"] == "turn.complete" + assert received[2]["sourceMessageId"] == "hermes-user:hermes-turn-1" + assert received[2]["outcome"] == "success" + assert received[2]["threadId"] == "thread-1" + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_real_stream_consumer_does_not_complete_turn_at_segment_boundaries( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + consumer = GatewayStreamConsumer( + adapter=adapter, + chat_id="chat-1", + config=StreamConsumerConfig(edit_interval=0, buffer_threshold=1, cursor=""), + metadata={"threadId": "thread-1"}, + ) + try: + task = asyncio.create_task(consumer.run()) + consumer.on_delta("I will inspect that first.") + await asyncio.sleep(0.1) + consumer.on_segment_break() + await asyncio.sleep(0.1) + consumer.on_commentary("Inspecting the repository") + await asyncio.sleep(0.1) + consumer.on_delta("The final answer") + consumer.finish() + await asyncio.wait_for(task, timeout=2) + + assert sum(frame["type"] == "message.send" for frame in received) >= 2 + assert any( + frame["type"] in {"message.send", "message.edit"} and frame["final"] + for frame in received + ) + assert all(frame["type"] != "turn.complete" for frame in received) + + await adapter.on_processing_complete( + SimpleNamespace( + source=SimpleNamespace(chat_id="chat-1", thread_id="thread-1"), + message_id="hermes-user:hermes-stream-turn-1", + ), + SimpleNamespace(value="success"), + ) + await wait_until(lambda: any(frame["type"] == "turn.complete" for frame in received)) + assert received[-1]["type"] == "turn.complete" + assert received[-1]["sourceMessageId"] == "hermes-user:hermes-stream-turn-1" + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_busy_slash_command_uses_its_own_source_and_completes( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + source = SessionSource( + platform=fake_platform, + chat_id="t3agent", + chat_type="thread", + user_id="owner", + thread_id="thread-1", + ) + active_event = adapter_module.MessageEvent( + text="work", + source=source, + message_id="hermes-user:turn-a", + ) + command_event = adapter_module.MessageEvent( + text="/status", + source=source, + message_id="hermes-user:turn-b", + ) + session_key = build_session_key( + source, + group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False), + ) + adapter._active_sessions[session_key] = asyncio.Event() + await adapter.on_processing_start(active_event) + + async def handle(_: Any) -> str: + return "Hermes is busy" + + adapter._message_handler = handle + try: + await adapter.handle_message(command_event) + await wait_until(lambda: len(received) >= 2) + assert received[0]["type"] == "message.send" + assert received[0]["sourceMessageId"] == "hermes-user:turn-b" + assert received[1]["type"] == "turn.complete" + assert received[1]["sourceMessageId"] == "hermes-user:turn-b" + assert adapter._processing_sources[("t3agent", "thread-1")] == "hermes-user:turn-a" + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_busy_slash_command_reports_handler_failure( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + source = SessionSource( + platform=fake_platform, + chat_id="t3agent", + chat_type="thread", + user_id="owner", + thread_id="thread-1", + ) + command_event = adapter_module.MessageEvent( + text="/status", + source=source, + message_id="hermes-user:failed-command", + ) + session_key = build_session_key( + source, + group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True), + thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False), + ) + adapter._active_sessions[session_key] = asyncio.Event() + + async def fail(_: Any) -> str: + raise RuntimeError("command failed") + + adapter._message_handler = fail + try: + await adapter.handle_message(command_event) + await wait_until(lambda: len(received) == 1) + assert received[0]["type"] == "turn.complete" + assert received[0]["sourceMessageId"] == "hermes-user:failed-command" + assert received[0]["outcome"] == "failure" + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_completion_outbox_recovers_after_t3_restart( + fake_platform: SimpleNamespace, + tmp_path: Any, + unused_tcp_port: int, +) -> None: + outbox_path = tmp_path / "completion-outbox.json" + first = adapter_module.T3AgentAdapter( + make_config(outbox_path=str(outbox_path), bridge_url="http://127.0.0.1:1") + ) + first._client = ClientSession() + event = SimpleNamespace( + source=SimpleNamespace(chat_id="t3agent", thread_id="thread-1"), + message_id="hermes-user:durable-turn", + ) + await first.on_processing_complete(event, SimpleNamespace(value="success")) + assert json.loads(outbox_path.read_text(encoding="utf-8")) == [ + { + "chatId": "t3agent", + "sourceMessageId": "hermes-user:durable-turn", + "outcome": "success", + "threadId": "thread-1", + } + ] + await first.disconnect() + + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + second = adapter_module.T3AgentAdapter( + make_config( + outbox_path=str(outbox_path), + bridge_url=str(server.make_url("/")), + ingress_port=unused_tcp_port, + ) + ) + try: + assert await second.connect() is True + await wait_until(lambda: len(received) == 1) + assert received[0]["type"] == "turn.complete" + assert received[0]["sourceMessageId"] == "hermes-user:durable-turn" + await wait_until( + lambda: json.loads(outbox_path.read_text(encoding="utf-8")) == [] + ) + finally: + await second.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_completion_fails_closed_when_outbox_cannot_be_persisted( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + blocked_parent = tmp_path / "not-a-directory" + blocked_parent.write_text("blocked", encoding="utf-8") + adapter = adapter_module.T3AgentAdapter( + make_config( + outbox_path=str(blocked_parent / "outbox.json"), + bridge_url=str(server.make_url("/")), + ) + ) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + event = SimpleNamespace( + source=SimpleNamespace(chat_id="t3agent", thread_id="thread-1"), + message_id="hermes-user:unpersisted-completion", + ) + try: + with pytest.raises(OSError): + await adapter.on_processing_complete(event, SimpleNamespace(value="success")) + await wait_until(lambda: len(received) >= 1) + assert received[0]["type"] == "turn.complete" + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_repeated_handoff_names_create_distinct_threads( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + "threadId": f"thread-{len(received)}", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + try: + assert await adapter.create_handoff_thread("t3agent", "Research") == "thread-1" + assert await adapter.create_handoff_thread("t3agent", "Research") == "thread-2" + assert received[0]["name"] == received[1]["name"] == "Research" + assert received[0]["occurrenceId"] != received[1]["occurrenceId"] + assert received[0]["deliveryId"] != received[1]["deliveryId"] + finally: + await adapter.disconnect() + await server.close() + + +@pytest.mark.asyncio +async def test_send_image_file_posts_inline_attachment( + fake_platform: SimpleNamespace, + tmp_path: Any, +) -> None: + received: List[Dict[str, Any]] = [] + image_path = tmp_path / "generated.png" + image_path.write_bytes(b"\x89PNG\r\n\x1a\n") + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + try: + result = await adapter.send_image_file( + "t3agent", + str(image_path), + caption="Generated image", + metadata={"thread_id": "thread-1"}, + ) + assert result.success is True + assert received[0]["content"] == "Generated image" + assert received[0]["threadId"] == "thread-1" + assert received[0]["images"][0]["source"] == { + "type": "data-url", + "dataUrl": "data:image/png;base64,iVBORw0KGgo=", + } + second_path = tmp_path / "generated-second.png" + second_path.write_bytes(b"\x89PNG\r\n\x1a\nsecond") + second = await adapter.send_image_file( + "t3agent", + str(second_path), + caption="Generated image", + metadata={"thread_id": "thread-1"}, + ) + assert second.success is True + assert received[0]["messageId"] != received[1]["messageId"] + finally: + await adapter._client.close() + adapter._client = None + await server.close() + + +@pytest.mark.asyncio +async def test_send_image_fetches_remote_media_through_hermes_safe_cache( + fake_platform: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + received: List[Dict[str, Any]] = [] + image_path = tmp_path / "remote.png" + image_path.write_bytes(b"\x89PNG\r\n\x1a\nremote") + + async def cache_remote(url: str, ext: str = ".jpg", retries: int = 2) -> str: + assert url == "https://images.example/result.png" + assert ext == ".png" + assert retries == 2 + return str(image_path) + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + monkeypatch.setattr(adapter_module, "cache_image_from_url", cache_remote) + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + adapter = adapter_module.T3AgentAdapter(make_config(bridge_url=str(server.make_url("/")))) + adapter.bridge_url = adapter.bridge_url.rstrip("/") + adapter._client = ClientSession() + try: + result = await adapter.send_image( + "t3agent", + "https://images.example/result.png", + caption="Remote result", + metadata={"thread_id": "thread-1"}, + ) + assert result.success is True + assert received[0]["content"] == "Remote result" + assert received[0]["images"][0]["source"]["type"] == "data-url" + finally: + await adapter._client.close() + adapter._client = None + await server.close() + + +@pytest.mark.asyncio +async def test_slash_confirmation_response_uses_public_resolver( + fake_platform: SimpleNamespace, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tools import slash_confirm + + calls: List[tuple[str, str, str]] = [] + + async def resolve(session_key: str, confirm_id: str, choice: str) -> str: + calls.append((session_key, confirm_id, choice)) + return "confirmed" + + monkeypatch.setattr(slash_confirm, "resolve", resolve) + adapter = adapter_module.T3AgentAdapter(make_config()) + client = await make_ingress_client(adapter) + try: + response = await client.post( + "/v1/slash-confirmations", + headers=auth_headers(), + json={ + "protocolVersion": 1, + "requestId": "confirm-response-1", + "type": "slash-confirmation.respond", + "sessionKey": "session-1", + "confirmId": "confirm-1", + "choice": "always", + }, + ) + assert response.status == 200 + assert calls == [("session-1", "confirm-1", "always")] + finally: + await client.close() + + +def test_validate_config_rejects_non_loopback_ingress( + fake_platform: SimpleNamespace, +) -> None: + assert adapter_module.validate_config(make_config()) is True + assert adapter_module.validate_config(make_config(ingress_host="0.0.0.0")) is False + + +@pytest.mark.asyncio +async def test_standalone_cron_send_uses_final_message_event( + fake_platform: SimpleNamespace, +) -> None: + received: List[Dict[str, Any]] = [] + + async def receive(request: web.Request) -> web.Response: + frame = await request.json() + received.append(frame) + return web.json_response( + { + "protocolVersion": 1, + "requestId": frame["requestId"], + "deliveryId": frame["deliveryId"], + "status": "accepted", + } + ) + + app = web.Application() + app.router.add_post("/api/hermes/hermes-test/events", receive) + server = TestServer(app) + await server.start_server() + try: + result = await adapter_module._standalone_send( + make_config(bridge_url=str(server.make_url("/"))), + "t3agent", + "scheduled result", + thread_id="thread-1", + ) + assert result["success"] is True + assert received[0]["type"] == "message.send" + assert received[0]["chatId"] == "t3agent" + assert received[0]["threadId"] == "thread-1" + assert received[0]["content"] == "scheduled result" + assert received[0]["final"] is True + + repeated = await adapter_module._standalone_send( + make_config(bridge_url=str(server.make_url("/"))), + "t3agent", + "scheduled result", + thread_id="thread-1", + ) + assert repeated["success"] is True + assert received[1]["messageId"] != received[0]["messageId"] + assert received[1]["deliveryId"] != received[0]["deliveryId"] + finally: + await server.close() diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e1acf1e948e..fb500518806 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -18,6 +18,10 @@ "./relay": { "types": "./src/relay.ts", "import": "./src/relay.ts" + }, + "./hermesBridge": { + "types": "./src/hermesBridge.ts", + "import": "./src/hermesBridge.ts" } }, "scripts": { diff --git a/packages/contracts/src/hermesBridge.test.ts b/packages/contracts/src/hermesBridge.test.ts new file mode 100644 index 00000000000..871db9d7c71 --- /dev/null +++ b/packages/contracts/src/hermesBridge.test.ts @@ -0,0 +1,440 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + HermesBridgeAcknowledgement, + HermesBridgeApprovalRequest, + HermesBridgeApprovalResponseRequest, + HermesBridgeCapabilitiesResponse, + HermesBridgeClarificationRequest, + HermesBridgeClarificationResponseRequest, + HermesBridgeHermesToT3Request, + HermesBridgeInboundMessageRequest, + HermesBridgeSlashConfirmationRequest, + HermesBridgeSlashConfirmationResponseRequest, + HermesBridgeT3ToHermesRequest, + HermesBridgeThreadCreateRequest, + HermesBridgeThreadCreateResponse, +} from "./hermesBridge.ts"; + +const decodeInboundMessage = Schema.decodeUnknownSync(HermesBridgeInboundMessageRequest); +const decodeT3ToHermes = Schema.decodeUnknownSync(HermesBridgeT3ToHermesRequest); +const decodeHermesToT3 = Schema.decodeUnknownSync(HermesBridgeHermesToT3Request); +const decodeApprovalRequest = Schema.decodeUnknownSync(HermesBridgeApprovalRequest); +const decodeApprovalResponse = Schema.decodeUnknownSync(HermesBridgeApprovalResponseRequest); +const decodeClarificationRequest = Schema.decodeUnknownSync(HermesBridgeClarificationRequest); +const decodeClarificationResponse = Schema.decodeUnknownSync( + HermesBridgeClarificationResponseRequest, +); +const decodeSlashConfirmationRequest = Schema.decodeUnknownSync( + HermesBridgeSlashConfirmationRequest, +); +const decodeSlashConfirmationResponse = Schema.decodeUnknownSync( + HermesBridgeSlashConfirmationResponseRequest, +); +const decodeCapabilitiesResponse = Schema.decodeUnknownSync(HermesBridgeCapabilitiesResponse); +const decodeAcknowledgement = Schema.decodeUnknownSync(HermesBridgeAcknowledgement); +const decodeThreadCreateRequest = Schema.decodeUnknownSync(HermesBridgeThreadCreateRequest); +const decodeThreadCreateResponse = Schema.decodeUnknownSync(HermesBridgeThreadCreateResponse); + +const requestFields = { + protocolVersion: 1, + requestId: "request-1", +} as const; + +const callbackFields = { + ...requestFields, + deliveryId: "delivery-1", +} as const; + +describe("Hermes bridge T3 to Hermes requests", () => { + it("decodes an image-bearing inbound message and preserves future fields", () => { + const decoded = decodeInboundMessage({ + ...requestFields, + type: "message.submit", + messageId: "message-1", + chatId: "chat-1", + threadId: "thread-1", + user: { + id: "owner-1", + name: "Owner", + futureUserField: "preserved", + }, + content: "Inspect this image", + images: [ + { + type: "image", + id: "image-1", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 42, + source: { + type: "local-path", + path: "/tmp/screen.png", + futureSourceField: true, + }, + futureImageField: 1, + }, + ], + futureMessageField: { supportedLater: true }, + }); + + expect(decoded.futureMessageField).toEqual({ supportedLater: true }); + expect(decoded.user.futureUserField).toBe("preserved"); + expect(decoded.images?.[0]?.futureImageField).toBe(1); + expect(decoded.images?.[0]?.source.futureSourceField).toBe(true); + }); + + it("accepts every T3 to Hermes request discriminant", () => { + const requests = [ + { + ...requestFields, + type: "message.submit", + messageId: "message-1", + chatId: "chat-1", + user: { id: "owner-1", name: "Owner" }, + content: "hello", + }, + { ...requestFields, type: "turn.interrupt" }, + { + ...requestFields, + type: "approval.respond", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + choice: "approve_once", + }, + { + ...requestFields, + type: "clarification.respond", + sessionKey: "session-1", + clarifyId: "clarify-1", + providerRequestId: "provider-request-2", + response: { answer: "Tailscale" }, + }, + { + ...requestFields, + type: "slash-confirmation.respond", + sessionKey: "session-1", + confirmId: "confirm-1", + choice: "always", + }, + ]; + + expect(requests.map((request) => decodeT3ToHermes(request).type)).toEqual([ + "message.submit", + "turn.interrupt", + "approval.respond", + "clarification.respond", + "slash-confirmation.respond", + ]); + }); + + it("rejects missing correlation fields and unsupported protocol versions", () => { + expect(() => + decodeInboundMessage({ + protocolVersion: 1, + type: "message.submit", + messageId: "message-1", + chatId: "chat-1", + user: { id: "owner-1", name: "Owner" }, + content: "hello", + }), + ).toThrow(); + + expect(() => + decodeInboundMessage({ + ...requestFields, + protocolVersion: 2, + type: "message.submit", + messageId: "message-1", + chatId: "chat-1", + user: { id: "owner-1", name: "Owner" }, + content: "hello", + }), + ).toThrow(); + }); + + it("validates approval and clarification provider correlation", () => { + expect( + decodeApprovalResponse({ + ...requestFields, + type: "approval.respond", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + choice: "approve_session", + }).providerRequestId, + ).toBe("provider-request-1"); + + expect( + decodeClarificationResponse({ + ...requestFields, + type: "clarification.respond", + sessionKey: "session-1", + clarifyId: "clarify-1", + providerRequestId: "provider-request-2", + response: ["one", "two"], + }).response, + ).toEqual(["one", "two"]); + + expect(() => + decodeApprovalResponse({ + ...requestFields, + type: "approval.respond", + sessionKey: "session-1", + approvalId: "approval-1", + choice: "approve_once", + }), + ).toThrow(); + }); + + it("limits slash confirmation responses to supported choices", () => { + expect( + decodeSlashConfirmationResponse({ + ...requestFields, + type: "slash-confirmation.respond", + sessionKey: "session-1", + confirmId: "confirm-1", + choice: "once", + }).choice, + ).toBe("once"); + + expect(() => + decodeSlashConfirmationResponse({ + ...requestFields, + type: "slash-confirmation.respond", + sessionKey: "session-1", + confirmId: "confirm-1", + choice: "yes", + }), + ).toThrow(); + }); +}); + +describe("Hermes bridge Hermes to T3 callbacks", () => { + it("decodes send and cumulative edit callbacks with final state", () => { + const send = decodeHermesToT3({ + ...callbackFields, + type: "message.send", + chatId: "chat-1", + threadId: "thread-1", + messageId: "message-1", + content: "partial", + final: false, + }); + const edit = decodeHermesToT3({ + ...callbackFields, + requestId: "request-2", + deliveryId: "delivery-2", + type: "message.edit", + threadId: "thread-1", + messageId: "message-1", + content: "partial response completed", + final: true, + futurePresentation: "markdown-v2", + }); + + expect(send.type).toBe("message.send"); + expect(send.final).toBe(false); + expect(edit.type).toBe("message.edit"); + expect(edit.final).toBe(true); + expect(edit.futurePresentation).toBe("markdown-v2"); + }); + + it("accepts delete, typing, interactions, confirmation, and thread creation", () => { + const callbacks = [ + { ...callbackFields, type: "message.delete", messageId: "message-1" }, + { ...callbackFields, type: "typing.set", active: true }, + { + ...callbackFields, + type: "turn.complete", + sourceMessageId: "user-message-1", + outcome: "success", + }, + { + ...callbackFields, + type: "approval.request", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + message: "Run the command?", + choices: [{ id: "once", label: "Approve once" }], + }, + { + ...callbackFields, + type: "clarification.request", + sessionKey: "session-1", + clarifyId: "clarify-1", + providerRequestId: "provider-request-2", + question: "Which network?", + choices: [], + }, + { + ...callbackFields, + type: "slash-confirmation.request", + sessionKey: "session-1", + confirmId: "confirm-1", + title: "Restart Hermes", + message: "Restart the running gateway?", + }, + { + ...callbackFields, + type: "thread.create", + parentChatId: "chat-1", + name: "Nightly report", + occurrenceId: "nightly-report-1", + }, + ]; + + expect(callbacks.map((callback) => decodeHermesToT3(callback).type)).toEqual([ + "message.delete", + "typing.set", + "turn.complete", + "approval.request", + "clarification.request", + "slash-confirmation.request", + "thread.create", + ]); + }); + + it("requires stable delivery and message lifecycle fields", () => { + expect(() => + decodeHermesToT3({ + ...requestFields, + type: "message.send", + messageId: "message-1", + content: "done", + final: true, + }), + ).toThrow(); + + expect(() => + decodeHermesToT3({ + ...callbackFields, + type: "message.edit", + messageId: "message-1", + content: "done", + }), + ).toThrow(); + }); + + it("validates provider requests and choices", () => { + const approval = decodeApprovalRequest({ + ...callbackFields, + type: "approval.request", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + message: "Allow shell command?", + choices: [ + { id: "once", label: "Approve once" }, + { id: "deny", label: "Deny", description: "Do not run it" }, + ], + }); + const clarification = decodeClarificationRequest({ + ...callbackFields, + type: "clarification.request", + sessionKey: "session-1", + clarifyId: "clarify-1", + providerRequestId: "provider-request-2", + question: "Where should the cron output go?", + choices: [{ id: "origin", label: "Origin thread" }], + }); + const confirmation = decodeSlashConfirmationRequest({ + ...callbackFields, + type: "slash-confirmation.request", + sessionKey: "session-1", + confirmId: "confirm-1", + title: "Restart Hermes", + message: "This interrupts active work.", + }); + + expect(approval.choices).toHaveLength(2); + expect(clarification.providerRequestId).toBe("provider-request-2"); + expect(confirmation.confirmId).toBe("confirm-1"); + + expect(() => + decodeApprovalRequest({ + ...callbackFields, + type: "approval.request", + sessionKey: "session-1", + approvalId: "approval-1", + providerRequestId: "provider-request-1", + message: "Allow?", + choices: [], + }), + ).toThrow(); + }); +}); + +describe("Hermes bridge discovery and responses", () => { + it("requires a logical occurrence id when creating a thread", () => { + const decoded = decodeThreadCreateRequest({ + ...callbackFields, + type: "thread.create", + parentChatId: "t3agent", + name: "Research", + occurrenceId: "occurrence-1", + }); + expect(decoded.occurrenceId).toBe("occurrence-1"); + expect(() => + decodeThreadCreateRequest({ + ...callbackFields, + type: "thread.create", + parentChatId: "t3agent", + name: "Research", + }), + ).toThrow(); + }); + + it("decodes capabilities and the command catalog", () => { + const decoded = decodeCapabilitiesResponse({ + ...requestFields, + capabilities: { + asynchronousDelivery: true, + imageAttachments: true, + interrupts: true, + approvals: true, + clarifications: true, + slashConfirmations: true, + threadCreation: true, + commandCatalog: true, + futureCapability: "voice-messages", + }, + commands: [ + { + name: "restart", + description: "Restart Hermes", + aliases: ["reboot"], + futureCommandField: true, + }, + ], + provider: "openrouter", + model: "model-1", + }); + + expect(decoded.capabilities.futureCapability).toBe("voice-messages"); + expect(decoded.commands[0]?.futureCommandField).toBe(true); + }); + + it("correlates acknowledgements and created threads", () => { + expect( + decodeAcknowledgement({ + ...requestFields, + deliveryId: "delivery-1", + status: "duplicate", + }).deliveryId, + ).toBe("delivery-1"); + + expect( + decodeThreadCreateResponse({ + ...requestFields, + deliveryId: "delivery-1", + chatId: "chat-2", + threadId: "thread-2", + futureThreadField: "preserved", + }).futureThreadField, + ).toBe("preserved"); + }); +}); diff --git a/packages/contracts/src/hermesBridge.ts b/packages/contracts/src/hermesBridge.ts new file mode 100644 index 00000000000..374872a4501 --- /dev/null +++ b/packages/contracts/src/hermesBridge.ts @@ -0,0 +1,361 @@ +import * as Schema from "effect/Schema"; + +import { MessageId, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const HERMES_BRIDGE_MAX_IMAGES = 8; +export const HERMES_BRIDGE_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; + +/** + * Wire contracts shared by the T3 Agent server and the Hermes `t3` platform + * adapter. Authentication belongs to the HTTP transport (Authorization + * bearer), so credentials are deliberately excluded from persisted payloads. + */ + +export const HERMES_BRIDGE_PROTOCOL_VERSION = 1 as const; +export const HermesBridgeProtocolVersion = Schema.Literal(HERMES_BRIDGE_PROTOCOL_VERSION); +export type HermesBridgeProtocolVersion = typeof HermesBridgeProtocolVersion.Type; + +const UnknownFields = Schema.Record(Schema.String, Schema.Unknown); + +const openStruct = (fields: Fields) => + Schema.StructWithRest(Schema.Struct(fields), [UnknownFields] as const); + +const makeBridgeId = (brand: Brand) => + TrimmedNonEmptyString.pipe(Schema.brand(brand)); + +export const HermesBridgeRequestId = makeBridgeId("HermesBridgeRequestId"); +export type HermesBridgeRequestId = typeof HermesBridgeRequestId.Type; + +export const HermesBridgeDeliveryId = makeBridgeId("HermesBridgeDeliveryId"); +export type HermesBridgeDeliveryId = typeof HermesBridgeDeliveryId.Type; + +export const HermesBridgeChatId = makeBridgeId("HermesBridgeChatId"); +export type HermesBridgeChatId = typeof HermesBridgeChatId.Type; + +export const HermesBridgeThreadId = makeBridgeId("HermesBridgeThreadId"); +export type HermesBridgeThreadId = typeof HermesBridgeThreadId.Type; + +export const HermesBridgeSessionKey = makeBridgeId("HermesBridgeSessionKey"); +export type HermesBridgeSessionKey = typeof HermesBridgeSessionKey.Type; + +export const HermesBridgeProviderRequestId = makeBridgeId("HermesBridgeProviderRequestId"); +export type HermesBridgeProviderRequestId = typeof HermesBridgeProviderRequestId.Type; + +export const HermesBridgeApprovalId = makeBridgeId("HermesBridgeApprovalId"); +export type HermesBridgeApprovalId = typeof HermesBridgeApprovalId.Type; + +export const HermesBridgeClarifyId = makeBridgeId("HermesBridgeClarifyId"); +export type HermesBridgeClarifyId = typeof HermesBridgeClarifyId.Type; + +export const HermesBridgeConfirmationId = makeBridgeId("HermesBridgeConfirmationId"); +export type HermesBridgeConfirmationId = typeof HermesBridgeConfirmationId.Type; + +export const HermesBridgeImageAttachmentId = makeBridgeId("HermesBridgeImageAttachmentId"); +export type HermesBridgeImageAttachmentId = typeof HermesBridgeImageAttachmentId.Type; + +const RequestFields = { + protocolVersion: HermesBridgeProtocolVersion, + requestId: HermesBridgeRequestId, +} as const; + +const CallbackFields = { + ...RequestFields, + deliveryId: HermesBridgeDeliveryId, +} as const; + +export const HermesBridgeImageSource = Schema.Union([ + openStruct({ + type: Schema.Literal("local-path"), + path: TrimmedNonEmptyString, + }), + openStruct({ + type: Schema.Literal("url"), + url: TrimmedNonEmptyString, + }), + openStruct({ + type: Schema.Literal("data-url"), + dataUrl: TrimmedNonEmptyString.check( + Schema.isMaxLength(HERMES_BRIDGE_MAX_IMAGE_DATA_URL_CHARS), + ), + }), +]); +export type HermesBridgeImageSource = typeof HermesBridgeImageSource.Type; + +export const HermesBridgeImageAttachment = openStruct({ + type: Schema.Literal("image"), + id: HermesBridgeImageAttachmentId, + name: TrimmedNonEmptyString, + mimeType: TrimmedNonEmptyString.check(Schema.isPattern(/^image\//i)), + sizeBytes: Schema.optionalKey(NonNegativeInt), + source: HermesBridgeImageSource, +}); +export type HermesBridgeImageAttachment = typeof HermesBridgeImageAttachment.Type; + +export const HermesBridgeUser = openStruct({ + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, +}); +export type HermesBridgeUser = typeof HermesBridgeUser.Type; + +export const HermesBridgeChoice = openStruct({ + id: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + description: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeChoice = typeof HermesBridgeChoice.Type; + +// T3 Agent -> Hermes + +export const HermesBridgeInboundMessageRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("message.submit"), + messageId: MessageId, + chatId: HermesBridgeChatId, + threadId: Schema.optionalKey(HermesBridgeThreadId), + user: HermesBridgeUser, + content: Schema.String, + images: Schema.optionalKey( + Schema.Array(HermesBridgeImageAttachment).check(Schema.isMaxLength(HERMES_BRIDGE_MAX_IMAGES)), + ), +}); +export type HermesBridgeInboundMessageRequest = typeof HermesBridgeInboundMessageRequest.Type; + +export const HermesBridgeInterruptRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("turn.interrupt"), + sessionKey: Schema.optionalKey(HermesBridgeSessionKey), + chatId: Schema.optionalKey(HermesBridgeChatId), + threadId: Schema.optionalKey(HermesBridgeThreadId), +}); +export type HermesBridgeInterruptRequest = typeof HermesBridgeInterruptRequest.Type; + +export const HermesBridgeApprovalResponseRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("approval.respond"), + sessionKey: HermesBridgeSessionKey, + approvalId: HermesBridgeApprovalId, + providerRequestId: HermesBridgeProviderRequestId, + choice: TrimmedNonEmptyString, + reason: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeApprovalResponseRequest = typeof HermesBridgeApprovalResponseRequest.Type; + +export const HermesBridgeClarificationResponseRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("clarification.respond"), + sessionKey: HermesBridgeSessionKey, + clarifyId: HermesBridgeClarifyId, + providerRequestId: HermesBridgeProviderRequestId, + response: Schema.Unknown, +}); +export type HermesBridgeClarificationResponseRequest = + typeof HermesBridgeClarificationResponseRequest.Type; + +export const HermesBridgeSlashConfirmationChoice = Schema.Literals(["once", "always", "cancel"]); +export type HermesBridgeSlashConfirmationChoice = typeof HermesBridgeSlashConfirmationChoice.Type; + +export const HermesBridgeSlashConfirmationResponseRequest = openStruct({ + ...RequestFields, + type: Schema.Literal("slash-confirmation.respond"), + sessionKey: HermesBridgeSessionKey, + confirmId: HermesBridgeConfirmationId, + choice: HermesBridgeSlashConfirmationChoice, +}); +export type HermesBridgeSlashConfirmationResponseRequest = + typeof HermesBridgeSlashConfirmationResponseRequest.Type; + +export const HermesBridgeT3ToHermesRequest = Schema.Union([ + HermesBridgeInboundMessageRequest, + HermesBridgeInterruptRequest, + HermesBridgeApprovalResponseRequest, + HermesBridgeClarificationResponseRequest, + HermesBridgeSlashConfirmationResponseRequest, +]); +export type HermesBridgeT3ToHermesRequest = typeof HermesBridgeT3ToHermesRequest.Type; + +// Hermes -> T3 Agent callbacks + +const DestinationFields = { + chatId: Schema.optionalKey(HermesBridgeChatId), + threadId: Schema.optionalKey(HermesBridgeThreadId), + sourceMessageId: Schema.optionalKey(MessageId), +} as const; + +const MessageCallbackFields = { + ...CallbackFields, + ...DestinationFields, + messageId: MessageId, + content: Schema.String, + images: Schema.optionalKey( + Schema.Array(HermesBridgeImageAttachment).check(Schema.isMaxLength(HERMES_BRIDGE_MAX_IMAGES)), + ), + final: Schema.Boolean, +} as const; + +export const HermesBridgeSendMessageRequest = openStruct({ + ...MessageCallbackFields, + type: Schema.Literal("message.send"), +}); +export type HermesBridgeSendMessageRequest = typeof HermesBridgeSendMessageRequest.Type; + +export const HermesBridgeEditMessageRequest = openStruct({ + ...MessageCallbackFields, + type: Schema.Literal("message.edit"), +}); +export type HermesBridgeEditMessageRequest = typeof HermesBridgeEditMessageRequest.Type; + +export const HermesBridgeDeleteMessageRequest = openStruct({ + ...CallbackFields, + ...DestinationFields, + type: Schema.Literal("message.delete"), + messageId: MessageId, +}); +export type HermesBridgeDeleteMessageRequest = typeof HermesBridgeDeleteMessageRequest.Type; + +export const HermesBridgeTypingRequest = openStruct({ + ...CallbackFields, + ...DestinationFields, + type: Schema.Literal("typing.set"), + active: Schema.Boolean, +}); +export type HermesBridgeTypingRequest = typeof HermesBridgeTypingRequest.Type; + +export const HermesBridgeTurnCompleteRequest = openStruct({ + ...CallbackFields, + ...DestinationFields, + type: Schema.Literal("turn.complete"), + sourceMessageId: MessageId, + outcome: Schema.Literals(["success", "failure", "cancelled"]), +}); +export type HermesBridgeTurnCompleteRequest = typeof HermesBridgeTurnCompleteRequest.Type; + +const InteractiveCallbackFields = { + ...CallbackFields, + ...DestinationFields, + sessionKey: HermesBridgeSessionKey, + providerRequestId: HermesBridgeProviderRequestId, +} as const; + +export const HermesBridgeApprovalRequest = openStruct({ + ...InteractiveCallbackFields, + type: Schema.Literal("approval.request"), + approvalId: HermesBridgeApprovalId, + title: Schema.optionalKey(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + choices: Schema.NonEmptyArray(HermesBridgeChoice), +}); +export type HermesBridgeApprovalRequest = typeof HermesBridgeApprovalRequest.Type; + +export const HermesBridgeClarificationRequest = openStruct({ + ...InteractiveCallbackFields, + type: Schema.Literal("clarification.request"), + clarifyId: HermesBridgeClarifyId, + question: TrimmedNonEmptyString, + choices: Schema.Array(HermesBridgeChoice), +}); +export type HermesBridgeClarificationRequest = typeof HermesBridgeClarificationRequest.Type; + +export const HermesBridgeSlashConfirmationRequest = openStruct({ + ...CallbackFields, + ...DestinationFields, + type: Schema.Literal("slash-confirmation.request"), + sessionKey: HermesBridgeSessionKey, + confirmId: HermesBridgeConfirmationId, + title: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type HermesBridgeSlashConfirmationRequest = typeof HermesBridgeSlashConfirmationRequest.Type; + +export const HermesBridgeThreadCreateRequest = openStruct({ + ...CallbackFields, + type: Schema.Literal("thread.create"), + parentChatId: HermesBridgeChatId, + name: TrimmedNonEmptyString, + occurrenceId: TrimmedNonEmptyString, + sessionKey: Schema.optionalKey(HermesBridgeSessionKey), +}); +export type HermesBridgeThreadCreateRequest = typeof HermesBridgeThreadCreateRequest.Type; + +export const HermesBridgeHermesToT3Request = Schema.Union([ + HermesBridgeSendMessageRequest, + HermesBridgeEditMessageRequest, + HermesBridgeDeleteMessageRequest, + HermesBridgeTypingRequest, + HermesBridgeTurnCompleteRequest, + HermesBridgeApprovalRequest, + HermesBridgeClarificationRequest, + HermesBridgeSlashConfirmationRequest, + HermesBridgeThreadCreateRequest, +]); +export type HermesBridgeHermesToT3Request = typeof HermesBridgeHermesToT3Request.Type; + +// Health, discovery, and response payloads + +export const HermesBridgeHealthRequest = openStruct(RequestFields); +export type HermesBridgeHealthRequest = typeof HermesBridgeHealthRequest.Type; + +export const HermesBridgeHealthResponse = openStruct({ + ...RequestFields, + status: Schema.Literals(["healthy", "degraded"]), + instanceId: Schema.optionalKey(TrimmedNonEmptyString), + hermesVersion: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeHealthResponse = typeof HermesBridgeHealthResponse.Type; + +export const HermesBridgeCapabilitiesRequest = openStruct(RequestFields); +export type HermesBridgeCapabilitiesRequest = typeof HermesBridgeCapabilitiesRequest.Type; + +export const HermesBridgeCapabilities = openStruct({ + asynchronousDelivery: Schema.Boolean, + imageAttachments: Schema.Boolean, + interrupts: Schema.Boolean, + approvals: Schema.Boolean, + clarifications: Schema.Boolean, + slashConfirmations: Schema.Boolean, + threadCreation: Schema.Boolean, + commandCatalog: Schema.Boolean, +}); +export type HermesBridgeCapabilities = typeof HermesBridgeCapabilities.Type; + +export const HermesBridgeCommand = openStruct({ + name: TrimmedNonEmptyString, + description: Schema.optionalKey(TrimmedNonEmptyString), + inputHint: Schema.optionalKey(TrimmedNonEmptyString), + aliases: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), +}); +export type HermesBridgeCommand = typeof HermesBridgeCommand.Type; + +export const HermesBridgeCommandCatalogRequest = openStruct(RequestFields); +export type HermesBridgeCommandCatalogRequest = typeof HermesBridgeCommandCatalogRequest.Type; + +export const HermesBridgeCommandCatalogResponse = openStruct({ + ...RequestFields, + commands: Schema.Array(HermesBridgeCommand), +}); +export type HermesBridgeCommandCatalogResponse = typeof HermesBridgeCommandCatalogResponse.Type; + +export const HermesBridgeCapabilitiesResponse = openStruct({ + ...RequestFields, + capabilities: HermesBridgeCapabilities, + commands: Schema.Array(HermesBridgeCommand), + provider: Schema.optionalKey(TrimmedNonEmptyString), + model: Schema.optionalKey(TrimmedNonEmptyString), + profile: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeCapabilitiesResponse = typeof HermesBridgeCapabilitiesResponse.Type; + +export const HermesBridgeAcknowledgement = openStruct({ + ...RequestFields, + deliveryId: Schema.optionalKey(HermesBridgeDeliveryId), + status: Schema.Literals(["accepted", "duplicate", "rejected"]), + message: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type HermesBridgeAcknowledgement = typeof HermesBridgeAcknowledgement.Type; + +export const HermesBridgeThreadCreateResponse = openStruct({ + ...RequestFields, + deliveryId: HermesBridgeDeliveryId, + chatId: HermesBridgeChatId, + threadId: HermesBridgeThreadId, +}); +export type HermesBridgeThreadCreateResponse = typeof HermesBridgeThreadCreateResponse.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..8081a5422a5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -26,3 +26,4 @@ export * from "./review.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./rpc.ts"; +export * from "./hermesBridge.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8c74c13b89b..21c023eb1bd 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -218,4 +218,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ProviderDriverKind.make("hermes")]: "Hermes", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b01df310062..fa5224b5a20 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -784,6 +784,8 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, messageId: MessageId, + text: Schema.optional(Schema.String), + attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.optional(TurnId), createdAt: IsoDateTime, }); @@ -972,6 +974,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ messageId: MessageId, role: OrchestrationMessageRole, text: Schema.String, + replaceText: Schema.optional(Schema.Boolean), attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2f3c1aafe69..cedc40ed6ee 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -376,6 +376,58 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const HermesSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + bridgeUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://127.0.0.1:8789")), + Schema.annotateKey({ + title: "Hermes bridge URL", + description: "Loopback URL exposed by the independently managed Hermes T3 Agent plugin.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:8789", + clearWhenEmpty: "omit", + }, + }), + ), + ingressToken: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Hermes ingress token", + description: "Secret T3 Agent uses when sending requests to the local Hermes plugin.", + providerSettingsForm: { + control: "password", + placeholder: "Required", + clearWhenEmpty: "omit", + }, + }), + ), + callbackToken: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "T3 callback token", + description: "Secret Hermes uses when delivering replies and background work to T3 Agent.", + providerSettingsForm: { + control: "password", + placeholder: "Required", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["bridgeUrl", "ingressToken", "callbackToken"], + }, +); +export type HermesSettings = typeof HermesSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -420,6 +472,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -523,6 +576,14 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + bridgeUrl: Schema.optionalKey(TrimmedString), + ingressToken: Schema.optionalKey(TrimmedString), + callbackToken: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -545,6 +606,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 0e31ef7e02c64c22022e8ee34c4cc6183fa0aade Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 22 Jul 2026 19:15:06 -0700 Subject: [PATCH 2/3] fix: expose Hermes plugin entrypoint --- integrations/hermes/t3agent/__init__.py | 3 +++ integrations/hermes/t3agent/tests/test_adapter.py | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 integrations/hermes/t3agent/__init__.py diff --git a/integrations/hermes/t3agent/__init__.py b/integrations/hermes/t3agent/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/integrations/hermes/t3agent/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/integrations/hermes/t3agent/tests/test_adapter.py b/integrations/hermes/t3agent/tests/test_adapter.py index 67b9eb4fe4b..e47d637dd63 100644 --- a/integrations/hermes/t3agent/tests/test_adapter.py +++ b/integrations/hermes/t3agent/tests/test_adapter.py @@ -11,6 +11,7 @@ from gateway.session import SessionSource, build_session_key import pytest +from integrations.hermes import t3agent as plugin_package from integrations.hermes.t3agent import adapter as adapter_module @@ -56,6 +57,10 @@ def auth_headers() -> Dict[str, str]: return {"Authorization": "Bearer ingress-secret"} +def test_plugin_package_exports_register() -> None: + assert plugin_package.register is adapter_module.register + + async def wait_until(predicate: Any, *, timeout: float = 2.0) -> None: async def poll() -> None: while not predicate(): From f3c1b06486bd07b32f87d521926d8166062c0361 Mon Sep 17 00:00:00 2001 From: eimexdev Date: Wed, 22 Jul 2026 19:56:09 -0700 Subject: [PATCH 3/3] fix: present Hermes as the T3 Agent surface --- apps/web/src/branding.ts | 4 +- apps/web/src/components/ChatView.tsx | 21 +++-- apps/web/src/components/Sidebar.tsx | 85 +++++++++++++------ apps/web/src/components/chat/ChatComposer.tsx | 40 ++++++--- apps/web/src/components/chat/ChatHeader.tsx | 25 +++++- .../src/components/chat/DraftHeroHeadline.tsx | 9 ++ .../components/settings/SettingsPanels.tsx | 53 +++++++----- .../settings/SettingsSidebarNav.tsx | 8 +- .../src/components/sidebar/SidebarChrome.tsx | 5 +- apps/web/src/productMode.test.ts | 45 ++++++++++ apps/web/src/productMode.ts | 29 +++++++ apps/web/src/routes/__root.tsx | 5 +- 12 files changed, 255 insertions(+), 74 deletions(-) create mode 100644 apps/web/src/productMode.test.ts create mode 100644 apps/web/src/productMode.ts diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d03..a125aece4a4 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,5 +1,6 @@ import type { DesktopAppBranding } from "@t3tools/contracts"; import { formatAppDisplayName } from "./branding.logic"; +import { IS_T3_AGENT_MODE } from "./productMode"; function readInjectedDesktopAppBranding(): DesktopAppBranding | null { if (typeof window === "undefined") { @@ -16,7 +17,8 @@ export const HOSTED_APP_CHANNEL = hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; export const HOSTED_APP_CHANNEL_LABEL = HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; -export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; +export const APP_BASE_NAME = + injectedDesktopAppBranding?.baseName ?? (IS_T3_AGENT_MODE ? "T3 Agent" : "T3 Code"); export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? HOSTED_APP_CHANNEL_LABEL ?? diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7ad27ae6d1..b9c4491cb74 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -203,6 +203,7 @@ import { useThreadRefs, } from "../state/entities"; import { environmentShell } from "../state/shell"; +import { IS_T3_AGENT_MODE, T3_AGENT_PROVIDER_INSTANCE_ID } from "../productMode"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; @@ -2273,12 +2274,13 @@ function ChatViewContent(props: ChatViewProps) { const selectedProviderInstanceId = providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) ?.instanceId ?? null; - const activeProviderInstanceId = - selectedProviderInstanceId ?? - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; + const activeProviderInstanceId = IS_T3_AGENT_MODE + ? T3_AGENT_PROVIDER_INSTANCE_ID + : (selectedProviderInstanceId ?? + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null); const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( @@ -5216,7 +5218,7 @@ function ChatViewContent(props: ChatViewProps) { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > - {!rightPanelOpen ? panelLayoutControls : null} + {!IS_T3_AGENT_MODE && !rightPanelOpen ? panelLayoutControls : null} - {isGitRepo && ( + {isGitRepo && !IS_T3_AGENT_MODE && (
(IS_T3_AGENT_MODE ? allSidebarThreads.filter(isT3AgentThread) : allSidebarThreads), + [allSidebarThreads], + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -1188,9 +1193,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); - const projectExpanded = useUiStateStore((state) => + const storedProjectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), ); + const projectExpanded = IS_T3_AGENT_MODE || storedProjectExpanded; const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -2241,7 +2247,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
+
@@ -2882,6 +2887,15 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const t3AgentCompatibilityProject = sortedProjects[0]?.memberProjects[0] ?? null; + const handleCreateT3AgentThread = useCallback(() => { + if (!t3AgentCompatibilityProject) { + return; + } + void handleNewThread( + scopeProjectRef(t3AgentCompatibilityProject.environmentId, t3AgentCompatibilityProject.id), + ); + }, [handleNewThread, t3AgentCompatibilityProject]); return ( @@ -2935,36 +2949,55 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
- Projects + {IS_T3_AGENT_MODE ? "Conversations" : "Projects"} -
- + {IS_T3_AGENT_MODE ? ( } > - + - Add project + New conversation -
+ ) : ( +
+ + + + } + > + + + Add project + +
+ )}
{isManualProjectSorting ? ( @@ -3052,7 +3085,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( export default function Sidebar() { const projects = useProjects(); - const sidebarThreads = useThreadShells(); + const allSidebarThreads = useThreadShells(); + const sidebarThreads = useMemo( + () => (IS_T3_AGENT_MODE ? allSidebarThreads.filter(isT3AgentThread) : allSidebarThreads), + [allSidebarThreads], + ); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b9b03d09c8b..7d68adad7fc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -123,6 +123,11 @@ import { sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; +import { + IS_T3_AGENT_MODE, + isT3AgentProviderInstance, + T3_AGENT_PROVIDER_INSTANCE_ID, +} from "../../productMode"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; @@ -682,13 +687,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Instance-aware projection of the wire provider list. One entry per // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. - const providerInstanceEntries = useMemo>( - () => - sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), - ), - [providerStatuses, settings], - ); + const providerInstanceEntries = useMemo>(() => { + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ); + return IS_T3_AGENT_MODE + ? entries.filter((entry) => isT3AgentProviderInstance(entry.instanceId)) + : entries; + }, [providerStatuses, settings]); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? @@ -734,6 +740,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // const selectedInstanceId = useMemo(() => { const candidates: Array = [ + IS_T3_AGENT_MODE ? T3_AGENT_PROVIDER_INSTANCE_ID : null, composerDraft.activeProvider, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, @@ -2530,10 +2537,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentUnavailable.connection, )}` : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + ? "Hermes is unavailable. Check the connection in Settings." + : IS_T3_AGENT_MODE + ? "Message Hermes, attach images, or type / for commands" + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> @@ -2600,6 +2609,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) No provider available + ) : IS_T3_AGENT_MODE ? ( + + {selectedModelForPickerWithCustomFallback} + ) : ( )} - {isComposerFooterCompact ? ( + {IS_T3_AGENT_MODE ? null : isComposerFooterCompact ? ( ; rightPanelOpen: boolean; gitCwd: string | null; + hermesConnectionState: "connected" | "unavailable"; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -68,6 +70,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, rightPanelOpen, gitCwd, + hermesConnectionState, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -85,7 +88,21 @@ export const ChatHeader = memo(function ChatHeader({ {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} - {activeProjectName ? ( + {IS_T3_AGENT_MODE ? ( + + + Hermes + + {hermesConnectionState === "connected" ? "Connected" : "Unavailable"} + + + ) : activeProjectName ? ( - {activeProjectScripts && ( + {!IS_T3_AGENT_MODE && activeProjectScripts && ( )} - {showOpenInPicker && ( + {!IS_T3_AGENT_MODE && showOpenInPicker && ( )} - {activeProjectName && ( + {!IS_T3_AGENT_MODE && activeProjectName && ( 0; const shouldShowProjectMenu = canChooseProject; + if (IS_T3_AGENT_MODE) { + return ( +

+ What can Hermes help with? +

+ ); + } + const projectSelector = shouldShowProjectMenu ? ( isT3AgentProviderInstance(provider.instanceId)) + : allServerProviders; const primaryEnvironment = usePrimaryEnvironment(); const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -1012,14 +1016,18 @@ export function ProviderSettingsPanel() { () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), [providerUpdateCandidates], ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => + const visibleProviderSettings = PROVIDER_SETTINGS.filter((providerSettings) => { + if (IS_T3_AGENT_MODE) { + return providerSettings.provider === ProviderDriverKind.make("hermes"); + } + return ( providerSettings.provider !== "cursor" || serverProviders.some( (provider) => provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); + ) + ); + }); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; const lastCheckedAt = @@ -1170,6 +1178,7 @@ export function ProviderSettingsPanel() { } for (const [driver, list] of instancesByDriver) { if (visibleDriverKinds.has(driver)) continue; + if (IS_T3_AGENT_MODE) continue; for (const [id, instance] of list) { rows.push({ instanceId: id, @@ -1283,22 +1292,24 @@ export function ProviderSettingsPanel() { headerAction={
- - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - + {IS_T3_AGENT_MODE ? null : ( + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + )} item.to !== "/settings/source-control" && item.to !== "/settings/beta", + ) + : SETTINGS_NAV_ITEMS; const handleSectionClick = useCallback( (to: SettingsSectionPath) => { if (isMobile) { @@ -75,7 +81,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - {SETTINGS_NAV_ITEMS.map((item) => { + {visibleSettingsNavItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.to; return ( diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index c665f7741e7..4ea8b814547 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,6 +4,7 @@ import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { APP_STAGE_LABEL } from "../../branding"; +import { IS_T3_AGENT_MODE } from "../../productMode"; import { cn } from "../../lib/utils"; import { primaryServerConfigAtom } from "../../state/server"; import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; @@ -64,7 +65,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { onBackdrop ? "text-white/70" : "text-muted-foreground", )} > - Code + {IS_T3_AGENT_MODE ? "Agent" : "Code"} ); @@ -108,7 +109,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - + {IS_T3_AGENT_MODE ? null : } diff --git a/apps/web/src/productMode.test.ts b/apps/web/src/productMode.test.ts new file mode 100644 index 00000000000..67ddf5dd852 --- /dev/null +++ b/apps/web/src/productMode.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { isT3AgentProviderInstance, isT3AgentThread, resolveProductMode } from "./productMode"; + +describe("resolveProductMode", () => { + it.each(["1", "true", "t3agent", " T3Agent "])("enables T3 Agent for %j", (value) => { + expect(resolveProductMode(value)).toBe("t3agent"); + }); + + it.each([undefined, "", "0", "false", "nightly"])( + "keeps the upstream T3 Code shell for %j", + (value) => { + expect(resolveProductMode(value)).toBe("t3code"); + }, + ); +}); + +describe("T3 Agent provider policy", () => { + const hermes = ProviderInstanceId.make("hermes"); + const codex = ProviderInstanceId.make("codex"); + + it("recognizes only the Hermes provider instance", () => { + expect(isT3AgentProviderInstance(hermes)).toBe(true); + expect(isT3AgentProviderInstance(codex)).toBe(false); + }); + + it("keeps restored Hermes threads when the session identifies Hermes", () => { + expect( + isT3AgentThread({ + modelSelection: { instanceId: codex }, + session: { providerInstanceId: hermes }, + }), + ).toBe(true); + }); + + it("rejects threads with no Hermes provider identity", () => { + expect( + isT3AgentThread({ + modelSelection: { instanceId: codex }, + session: null, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/productMode.ts b/apps/web/src/productMode.ts new file mode 100644 index 00000000000..a5a6cf58c43 --- /dev/null +++ b/apps/web/src/productMode.ts @@ -0,0 +1,29 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; + +export type ProductMode = "t3code" | "t3agent"; + +export function resolveProductMode(value: string | undefined): ProductMode { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "t3agent" + ? "t3agent" + : "t3code"; +} + +export const PRODUCT_MODE = resolveProductMode(import.meta.env.VITE_T3_AGENT_MODE); +export const IS_T3_AGENT_MODE = PRODUCT_MODE === "t3agent"; +export const T3_AGENT_PROVIDER_INSTANCE_ID = ProviderInstanceId.make("hermes"); + +export function isT3AgentProviderInstance(instanceId: ProviderInstanceId): boolean { + return instanceId === T3_AGENT_PROVIDER_INSTANCE_ID; +} + +export function isT3AgentThread(thread: { + readonly modelSelection: { readonly instanceId: ProviderInstanceId }; + readonly session: { readonly providerInstanceId?: ProviderInstanceId | undefined } | null; +}): boolean { + return ( + isT3AgentProviderInstance(thread.modelSelection.instanceId) || + (thread.session?.providerInstanceId !== undefined && + isT3AgentProviderInstance(thread.session.providerInstanceId)) + ); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ff6bc5b3952..d8bbecd1ff2 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { import { useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; +import { IS_T3_AGENT_MODE } from "../productMode"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; @@ -134,7 +135,9 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated && !IS_T3_AGENT_MODE ? ( + + ) : null} {appShell}