History

feat: surface the Hermes model on instance status

`connection.hello` gained an optional `model` field, but the value died in the broker: `HermesGatewayInstanceStatus` carried pluginVersion and hermesVersion with nowhere for the model to go, so the provider picker had no way to name the model Hermes actually runs. Add `model` to the status struct as a required nullable field. Null covers both "no plugin has connected yet" and "the connected plugin predates the field" — the plugin ships separately from the server, so a plugin that never sends it must still complete the handshake rather than failing the frame decoder. Tests cover the present and absent paths on both the hello frame and the status projection. The model is volatile liveness, not durable config: it belongs in broker memory alongside pluginVersion/hermesVersion/capabilities and is never persisted to settings.

Ben Davis committed 3 months ago

Commit e8be9671 parent 5b519772 files · +44 −0Browse files

Unified · Split

2 files
  1. packages/contracts/src/hermesGateway.test.ts +37 −0
  2. packages/contracts/src/hermesGateway.ts +7 −0
packages/contracts/src/hermesGateway.test.ts+37−0
@@ -69,6 +69,7 @@ describe("Hermes gateway management contracts", () => { · 68 unmodified lines
69 lastConnectedAt: "2026-07-23T12:00:00.000Z",69 lastConnectedAt: "2026-07-23T12:00:00.000Z",
70 pluginVersion: "0.2.0",70 pluginVersion: "0.2.0",
71 hermesVersion: "1.2.3",71 hermesVersion: "1.2.3",
72 model: "gpt-5.6-terra",
72 activeSessionCount: 2,73 activeSessionCount: 2,
73 protocolVersion: 2,74 protocolVersion: 2,
74 capabilities: {75 capabilities: {
@@ -82,6 +83,12 @@ describe("Hermes gateway management contracts", () => { · 7 unmodified lines
82 });83 });
83 expect(connected.status).toBe("connected");84 expect(connected.status).toBe("connected");
84 expect(connected.activeSessionCount).toBe(2);85 expect(connected.activeSessionCount).toBe(2);
86 expect(connected.model).toBe("gpt-5.6-terra");
87
88 // A plugin that predates the `model` field still produces a valid status;
89 // the picker falls back to the generic label rather than failing to decode.
90 const withoutModel = decodeInstanceStatus({ ...connected, model: null });
91 expect(withoutModel.model).toBeNull();
85 92
86 const upgradeRequired = decodeInstanceStatus({93 const upgradeRequired = decodeInstanceStatus({
87 ...connected,94 ...connected,
@@ -134,6 +141,7 @@ describe("Hermes gateway handshake", () => { · 46 unmodified lines
134 userInput: true,141 userInput: true,
135 attachments: false,142 attachments: false,
136 },143 },
144 model: "gpt-5.6-terra",
137 authentication: {145 authentication: {
138 type: "instance-credential",146 type: "instance-credential",
139 instanceId: "hermes-research",147 instanceId: "hermes-research",
@@ -142,6 +150,35 @@ describe("Hermes gateway handshake", () => { · 2 unmodified lines
142 });150 });
143 151
144 expect(hello.authentication.type).toBe("instance-credential");152 expect(hello.authentication.type).toBe("instance-credential");
153 expect(hello.model).toBe("gpt-5.6-terra");
154 });
155
156 // The plugin ships separately from the server, so a plugin that predates the
157 // `model` field must still complete the handshake rather than failing the
158 // frame decoder. T3 falls back to the generic model label.
159 it("accepts a hello from a plugin that reports no model", () => {
160 const hello = decodeHello({
161 type: "connection.hello",
162 requestId: "hello-no-model",
163 protocolVersion: 2,
164 pluginVersion: "0.2.0",
165 hermesVersion: "1.2.3",
166 capabilities: {
167 protocolVersion: 2,
168 streaming: true,
169 activity: true,
170 approvals: true,
171 userInput: true,
172 attachments: false,
173 },
174 authentication: {
175 type: "instance-credential",
176 instanceId: "hermes-research",
177 credential: "persistent-secret",
178 },
179 });
180
181 expect(hello.model).toBeUndefined();
145 });182 });
146 183
147 it("decodes a future-version hello so the broker can reject it explicitly", () => {184 it("decodes a future-version hello so the broker can reject it explicitly", () => {
packages/contracts/src/hermesGateway.ts+7−0
@@ -123,6 +123,13 @@ export const HermesGatewayInstanceStatus = Schema.Struct({ · 122 unmodified lines
123 lastConnectedAt: Schema.NullOr(IsoDateTime),123 lastConnectedAt: Schema.NullOr(IsoDateTime),
124 pluginVersion: Schema.NullOr(TrimmedNonEmptyString),124 pluginVersion: Schema.NullOr(TrimmedNonEmptyString),
125 hermesVersion: Schema.NullOr(TrimmedNonEmptyString),125 hermesVersion: Schema.NullOr(TrimmedNonEmptyString),
126 /**
127 * The model the connected plugin reported at handshake, surfaced so the
128 * provider picker can name the model Hermes actually runs instead of a
129 * placeholder. Null when no plugin has connected yet, or when the connected
130 * plugin predates the `model` field on `connection.hello`.
131 */
132 model: Schema.NullOr(TrimmedNonEmptyString),
126 activeSessionCount: NonNegativeInt,133 activeSessionCount: NonNegativeInt,
127 protocolVersion: Schema.NullOr(PositiveInt),134 protocolVersion: Schema.NullOr(PositiveInt),
128 capabilities: Schema.NullOr(HermesGatewayCapabilities),135 capabilities: Schema.NullOr(HermesGatewayCapabilities),