-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Add Hermes and Pi agent provider support #2748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9ff2d8e
82f37bd
fbc1dd6
4fb4c16
2a33835
e518bdd
37da4ac
59a16ae
3e3e214
b55b151
7191bae
fc8a79f
66d98a1
79e9c8e
f46d704
9948b09
7a74198
2cf54e0
0b6010e
211f230
aee7d81
d439ab1
8eff67b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { spawn } from "node:child_process"; | ||
| import { watch } from "node:fs"; | ||
| import { stat } from "node:fs/promises"; | ||
| import { dirname, extname, resolve } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const scriptsDir = dirname(fileURLToPath(import.meta.url)); | ||
| const serverDir = resolve(scriptsDir, ".."); | ||
| const srcDir = resolve(serverDir, "src"); | ||
| const entry = resolve(srcDir, "bin.ts"); | ||
| const sourceExtensions = new Set([".cjs", ".cts", ".js", ".json", ".mjs", ".mts", ".ts"]); | ||
|
|
||
| let child; | ||
| let restartTimer; | ||
| let stopping = false; | ||
|
|
||
| function start() { | ||
| child = spawn(process.execPath, [entry], { | ||
| stdio: "inherit", | ||
| env: process.env, | ||
| cwd: serverDir, | ||
| }); | ||
|
|
||
| child.on("exit", (code, signal) => { | ||
| if (stopping || restartTimer) { | ||
| return; | ||
| } | ||
| if (signal) { | ||
| process.kill(process.pid, signal); | ||
| return; | ||
| } | ||
| process.exit(code ?? 0); | ||
| }); | ||
| } | ||
|
|
||
| async function shouldRestart(filename) { | ||
| if (!filename || typeof filename !== "string") { | ||
| return false; | ||
| } | ||
|
|
||
| const changedPath = resolve(srcDir, filename); | ||
| if (!changedPath.startsWith(`${srcDir}/`)) { | ||
| return false; | ||
| } | ||
|
|
||
| try { | ||
| const changedStat = await stat(changedPath); | ||
| if (changedStat.isDirectory()) { | ||
| return false; | ||
| } | ||
| } catch { | ||
| // Deleted source files still need a restart. | ||
| } | ||
|
|
||
| return sourceExtensions.has(extname(changedPath)); | ||
| } | ||
|
|
||
| function scheduleRestart() { | ||
| clearTimeout(restartTimer); | ||
| restartTimer = setTimeout(() => { | ||
| restartTimer = undefined; | ||
| console.log("Restarting server..."); | ||
|
|
||
| const previous = child; | ||
| previous.once("exit", () => { | ||
| if (!stopping) { | ||
| start(); | ||
| } | ||
| }); | ||
| previous.kill("SIGTERM"); | ||
| }, 100); | ||
|
Comment on lines
+58
to
+71
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low If the child process exits on its own during the 100ms debounce window, the const previous = child;
+ if (previous.exitCode !== null || previous.killed) {
+ if (!stopping) {
+ start();
+ }
+ } else {
previous.once("exit", () => {
if (!stopping) {
start();
}
});
previous.kill("SIGTERM");
+ }🤖 Copy this AI Prompt to have your agent fix this: |
||
| } | ||
|
|
||
| const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => { | ||
| if (await shouldRestart(filename)) { | ||
| scheduleRestart(); | ||
| } | ||
| }); | ||
|
|
||
| function shutdown(signal) { | ||
| stopping = true; | ||
| clearTimeout(restartTimer); | ||
| watcher.close(); | ||
| if (!child || child.killed) { | ||
| process.exit(0); | ||
| } | ||
| child.once("exit", () => process.exit(0)); | ||
| child.kill(signal); | ||
| } | ||
|
|
||
| process.on("SIGINT", () => shutdown("SIGINT")); | ||
| process.on("SIGTERM", () => shutdown("SIGTERM")); | ||
|
|
||
| start(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| /** | ||
| * HermesDriver — `ProviderDriver` for the Hermes Agent (`hermes`) runtime. | ||
| * | ||
| * Hermes exposes an ACP-based CLI. The driver is still a plain value, but | ||
| * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` | ||
| * hook to run the slow ACP model-capability probe in the background without | ||
| * blocking the initial `ready`-state publish. | ||
| * | ||
| * Text generation is supported via the ACP runtime — `makeHermesTextGeneration` | ||
| * drives `runtime.prompt` with a structured-output schema and collects the | ||
| * agent's `agent_message_chunk` stream into a single JSON blob. | ||
| * | ||
| * @module provider/Drivers/HermesDriver | ||
| */ | ||
| import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | ||
| import * as Duration from "effect/Duration"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as FileSystem from "effect/FileSystem"; | ||
| import * as Path from "effect/Path"; | ||
| import * as Schema from "effect/Schema"; | ||
| import * as Stream from "effect/Stream"; | ||
| import { HttpClient } from "effect/unstable/http"; | ||
| import { ChildProcessSpawner } from "effect/unstable/process"; | ||
|
|
||
| import { ServerConfig } from "../../config.ts"; | ||
| import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts"; | ||
| import { ProviderDriverError } from "../Errors.ts"; | ||
| import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; | ||
| import { | ||
| buildInitialHermesProviderSnapshot, | ||
| checkHermesProviderStatus, | ||
| enrichHermesSnapshot, | ||
| } from "../Layers/HermesProvider.ts"; | ||
| import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| import { | ||
| defaultProviderContinuationIdentity, | ||
| type ProviderDriver, | ||
| type ProviderInstance, | ||
| } from "../ProviderDriver.ts"; | ||
| import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| import { | ||
| makeProviderMaintenanceCapabilities, | ||
| makeStaticProviderMaintenanceResolver, | ||
| resolveProviderMaintenanceCapabilitiesEffect, | ||
| } from "../providerMaintenance.ts"; | ||
| const decodeHermesSettings = Schema.decodeSync(HermesSettings); | ||
|
|
||
| const DRIVER_KIND = ProviderDriverKind.make("hermes"); | ||
| const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); | ||
| const UPDATE = makeStaticProviderMaintenanceResolver( | ||
| makeProviderMaintenanceCapabilities({ | ||
| provider: DRIVER_KIND, | ||
| packageName: null, | ||
| updateExecutable: "hermes", | ||
| updateArgs: ["update"], | ||
| updateLockKey: "hermes-agent", | ||
| }), | ||
| ); | ||
|
|
||
| export type HermesDriverEnv = | ||
| | ChildProcessSpawner.ChildProcessSpawner | ||
| | FileSystem.FileSystem | ||
| | HttpClient.HttpClient | ||
| | Path.Path | ||
| | ProviderEventLoggers | ||
| | ServerConfig; | ||
|
|
||
| const withInstanceIdentity = | ||
| (input: { | ||
| readonly instanceId: ProviderInstance["instanceId"]; | ||
| readonly displayName: string | undefined; | ||
| readonly accentColor: string | undefined; | ||
| readonly continuationGroupKey: string; | ||
| }) => | ||
| (snapshot: ServerProviderDraft): ServerProvider => ({ | ||
| ...snapshot, | ||
| instanceId: input.instanceId, | ||
| driver: DRIVER_KIND, | ||
| ...(input.displayName ? { displayName: input.displayName } : {}), | ||
| ...(input.accentColor ? { accentColor: input.accentColor } : {}), | ||
| continuation: { groupKey: input.continuationGroupKey }, | ||
| }); | ||
|
|
||
| export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = { | ||
| driverKind: DRIVER_KIND, | ||
| metadata: { | ||
| displayName: "Hermes", | ||
| supportsMultipleInstances: true, | ||
| }, | ||
| configSchema: HermesSettings, | ||
| defaultConfig: (): HermesSettings => decodeHermesSettings({}), | ||
| create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | ||
| Effect.gen(function* () { | ||
| const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| const fileSystem = yield* FileSystem.FileSystem; | ||
| const path = yield* Path.Path; | ||
| const httpClient = yield* HttpClient.HttpClient; | ||
| const eventLoggers = yield* ProviderEventLoggers; | ||
| const processEnv = mergeProviderInstanceEnvironment(environment); | ||
| const continuationIdentity = defaultProviderContinuationIdentity({ | ||
| driverKind: DRIVER_KIND, | ||
| instanceId, | ||
| }); | ||
| const stampIdentity = withInstanceIdentity({ | ||
| instanceId, | ||
| displayName, | ||
| accentColor, | ||
| continuationGroupKey: continuationIdentity.continuationKey, | ||
| }); | ||
| const effectiveConfig = { ...config, enabled } satisfies HermesSettings; | ||
| const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { | ||
| binaryPath: effectiveConfig.binaryPath, | ||
| env: processEnv, | ||
| }); | ||
|
|
||
| const adapter = yield* makeHermesAdapter(effectiveConfig, { | ||
| environment: processEnv, | ||
| ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), | ||
| instanceId, | ||
| }); | ||
| const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv); | ||
|
|
||
| const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe( | ||
| Effect.map(stampIdentity), | ||
| Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| Effect.provideService(Path.Path, path), | ||
| ); | ||
|
|
||
| const snapshot = yield* makeManagedServerProvider<HermesSettings>({ | ||
| maintenanceCapabilities, | ||
| getSettings: Effect.succeed(effectiveConfig), | ||
| streamSettings: Stream.never, | ||
| haveSettingsChanged: () => false, | ||
| initialSnapshot: (settings) => | ||
| buildInitialHermesProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), | ||
| checkProvider, | ||
| // Keep version-advisory enrichment off the initial provider snapshot | ||
| // so Hermes never blocks server startup or settings rendering. | ||
| enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => | ||
| enrichHermesSnapshot({ | ||
| snapshot: currentSnapshot, | ||
| maintenanceCapabilities, | ||
| publishSnapshot, | ||
| stampIdentity, | ||
| httpClient, | ||
| }), | ||
| refreshInterval: SNAPSHOT_REFRESH_INTERVAL, | ||
| }).pipe( | ||
| Effect.mapError( | ||
| (cause) => | ||
| new ProviderDriverError({ | ||
| driver: DRIVER_KIND, | ||
| instanceId, | ||
| detail: `Failed to build Hermes snapshot: ${cause.message ?? String(cause)}`, | ||
| cause, | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| return { | ||
| instanceId, | ||
| driverKind: DRIVER_KIND, | ||
| continuationIdentity, | ||
| displayName, | ||
| accentColor, | ||
| enabled, | ||
| snapshot, | ||
| adapter, | ||
| textGeneration, | ||
| } satisfies ProviderInstance; | ||
| }), | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dev watch path check fails on Windows
Low Severity
The
shouldRestartfunction checkschangedPath.startsWith(${srcDir}/)using a forward slash, but on Windowsresolve()produces backslash-separated paths. This causes the guard to always returnfalseon Windows, meaning file changes will never trigger a server restart during development.Reviewed by Cursor Bugbot for commit 8eff67b. Configure here.