Skip to content

Commit 8b96827

Browse files
committed
nives 2.5.1: accept null conversation ids, cache the stable prompt prefix (#63, #66)
Two fixes from alcohen83's report batch, both verified before shipping. #63 — conversation.process without a conversation_id always 400'd: HA's integration serialized the missing id as an explicit JSON null, which z.string().optional() rejects (optional covers ABSENT, not null). Fixed on both sides: the schema now uses nullish() and normalizes (userId too — same latent shape), and the integration omits the key entirely when it has no value. The failure also surfaced as "couldn't reach the Nives server", which misdirects diagnosis when the server is in fact answering — a ClientResponseError now gets its own message naming the HTTP status, and the raised error carries the response body so the log states WHY, not just that. #66 — prompt caching is a prefix match, and the per-request timestamp led the uncached block, so the home layout and device cheat sheet (the largest stable content we send) were re-billed on every turn. Reordered least-volatile-first on both engines: the Anthropic block path gives the home description its own cache breakpoint (a rescan invalidates it without evicting the instruction block), and the plain-text path moves timestamps and retrieved facts to the tail so provider-side automatic prefix caching can capture everything before them. Measured live on a 150-device fixture via the block path: repeat turns went from 4373/14008 tokens cached (identity only) to 13868/14008 (~99%). On the OpenAI-compatible path an identical-prefix repeat showed 7229/7232 cached and a 91% input-cost drop — though large-prefix hits are shard-sensitive there; a stable prompt_cache_key per install is a possible follow-up. Bundles server 0.15.1.
1 parent 2b9c512 commit 8b96827

10 files changed

Lines changed: 186 additions & 20 deletions

File tree

nives/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 2.5.1
4+
5+
- **Automations and service calls can now message Nives without a conversation id.** Calling `conversation.process` on the Nives agent without a `conversation_id` — the natural way to use it from an automation or script — was refused before the model ever saw it, with a misleading "couldn't reach the server" reply. It now just works. Voice and the Assist dialog were never affected. Thanks to @alcohen83 for the pinpoint report (#63).
6+
- **Error replies now say who actually failed.** When the Nives server answers a request with an error, the reply now says so — with the HTTP status — instead of suggesting the server was unreachable, and the add-on log carries the server's own explanation of what it objected to. Connection problems still say "couldn't reach".
7+
- **Repeat turns got cheaper: the unchanging parts of the prompt can now be cached.** With every message, Nives sends the model your home's layout and device capabilities so it always knows your home. Those now sit ahead of the parts that change each turn (timestamps, retrieved memories), so providers that cache prompts can reuse them between turns instead of billing them again on every "turn off the kitchen light". Measured on an Anthropic key with a mid-size home, repeat turns went from about a third of the prompt cached to ~99% — same content, same answers, noticeably cheaper. The bigger your home, the more this saves. Thanks again to @alcohen83 (#66). Bundles server 0.15.1.
8+
39
## 2.5.0
410

511
- **Nives now warns you before your balance runs out.** If you use Nives with a key from nives.house, the add-on keeps an eye on the remaining balance and, when about three days of typical use are left, posts a Home Assistant notification inviting you to top up. If the balance does run out, a clearer notification says so. Once you top up, both clear on their own and Nives carries on where it left off — nothing to configure, nothing to restart. If you bring your own key, nothing changes: Nives only watches nives.house balances.

nives/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "Nives"
2-
version: "2.5.0"
2+
version: "2.5.1"
33
slug: "nives"
44
description: "AI assistant with cognitive memory for Home Assistant"
55
url: "https://github.com/hoornet/nives"

nives/rootfs/opt/nives/conversation.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ async def async_process(self, user_input: ConversationInput) -> ConversationResu
123123
response=intent_response,
124124
conversation_id=conversation_id,
125125
)
126+
except aiohttp.ClientResponseError as err:
127+
# The server ANSWERED — with an error status. Saying "couldn't
128+
# reach the server" here sends people debugging connectivity when
129+
# the log already names the real problem (#63, and #1 before it).
130+
_LOGGER.error("Nives server returned an error: %s", err.message)
131+
intent_response = intent.IntentResponse(language=user_input.language)
132+
intent_response.async_set_error(
133+
intent.IntentResponseErrorCode.UNKNOWN,
134+
f"Sorry, the Nives server returned an error (HTTP {err.status}). "
135+
"The add-on log has the details.",
136+
)
137+
return ConversationResult(
138+
response=intent_response,
139+
conversation_id=conversation_id,
140+
)
126141
except (aiohttp.ClientError, TimeoutError) as err:
127142
_LOGGER.error("Error calling Nives API: %s", err)
128143
intent_response = intent.IntentResponse(language=user_input.language)
@@ -178,9 +193,13 @@ async def _call_api(
178193
payload: dict = {
179194
"message": message,
180195
"userId": user_id,
181-
"conversationId": conversation_id,
182196
"isVoice": is_voice,
183197
}
198+
# Only send the key when there is a conversation in flight. A service
199+
# call without a conversation_id would otherwise put an explicit null
200+
# on the wire, which request validation rejects (#63).
201+
if conversation_id:
202+
payload["conversationId"] = conversation_id
184203
# The Assist pipeline's language (e.g. "sl", "en"). Without it the model
185204
# has no anchor at all and infers the language from context that is
186205
# soaked in native-language entity names — which is how an English
@@ -208,11 +227,15 @@ async def _call_api(
208227
if response.status == 402:
209228
raise UsageLimitError()
210229
if response.status != 200:
230+
# Capture the body: the status alone says "the server
231+
# objected", the body says WHY (validation details, error
232+
# codes) — and this log line is all a bug report will carry.
233+
body = (await response.text())[:300]
211234
raise aiohttp.ClientResponseError(
212235
response.request_info,
213236
response.history,
214237
status=response.status,
215-
message=f"API error {response.status}",
238+
message=f"API error {response.status}: {body}",
216239
)
217240
data = await response.json()
218241
response_text = data.get("response")

nives/rootfs/opt/nives/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"integration_type": "service",
1010
"iot_class": "local_polling",
1111
"requirements": ["aiohttp>=3.8.0"],
12-
"version": "2.5.0"
12+
"version": "2.5.1"
1313
}

server/src/home-mind-server/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/home-mind-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@home-mind/server",
3-
"version": "0.15.0",
3+
"version": "0.15.1",
44
"description": "Home Mind API server - AI assistant with cognitive memory for Home Assistant",
55
"type": "module",
66
"main": "dist/index.js",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from "vitest";
2+
import { ChatRequestSchema } from "./routes.js";
3+
4+
describe("ChatRequestSchema (#63)", () => {
5+
// HA's conversation.process without a conversation_id serialized to an
6+
// explicit `"conversationId": null`, which `.optional()` rejects — every
7+
// such service call 400'd before any LLM was reached.
8+
9+
it("accepts conversationId: null and normalizes it to undefined", () => {
10+
const result = ChatRequestSchema.safeParse({
11+
message: "hello",
12+
conversationId: null,
13+
});
14+
expect(result.success).toBe(true);
15+
if (result.success) {
16+
expect(result.data.conversationId).toBeUndefined();
17+
}
18+
});
19+
20+
it("accepts an absent conversationId", () => {
21+
const result = ChatRequestSchema.safeParse({ message: "hello" });
22+
expect(result.success).toBe(true);
23+
});
24+
25+
it("passes a real conversationId through", () => {
26+
const result = ChatRequestSchema.safeParse({
27+
message: "hello",
28+
conversationId: "01ABCDEFGHIJKLMNOPQRSTUVWX",
29+
});
30+
expect(result.success).toBe(true);
31+
if (result.success) {
32+
expect(result.data.conversationId).toBe("01ABCDEFGHIJKLMNOPQRSTUVWX");
33+
}
34+
});
35+
36+
it("accepts userId: null and falls back to default — same latent shape", () => {
37+
const result = ChatRequestSchema.safeParse({
38+
message: "hello",
39+
userId: null,
40+
});
41+
expect(result.success).toBe(true);
42+
if (result.success) {
43+
expect(result.data.userId).toBe("default");
44+
}
45+
});
46+
47+
it("still requires a message", () => {
48+
const result = ChatRequestSchema.safeParse({ conversationId: null });
49+
expect(result.success).toBe(false);
50+
});
51+
});

server/src/home-mind-server/src/api/routes.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@ import type { ITtsService } from "../tts/tts-service.js";
1010
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
1111

1212
// Request validation schemas
13-
const ChatRequestSchema = z.object({
13+
export const ChatRequestSchema = z.object({
1414
message: z.string().min(1, "Message is required"),
15-
userId: z.string().default("default"),
16-
conversationId: z.string().optional(),
15+
// JSON callers send an explicit null for "no value" at least as often as
16+
// they omit the key — HA's conversation.process without a conversation_id
17+
// arrives as `"conversationId": null` (#63). `.optional()`/`.default()`
18+
// only cover ABSENT, so both fields accept null and normalize it here.
19+
userId: z
20+
.string()
21+
.nullish()
22+
.transform((v) => v ?? "default"),
23+
conversationId: z
24+
.string()
25+
.nullish()
26+
.transform((v) => v ?? undefined),
1727
isVoice: z.boolean().default(false),
1828
customPrompt: z.string().optional(),
1929
// The caller's UI language (e.g. "sl", "en-US") — in the add-on this is the

server/src/home-mind-server/src/llm/prompts.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,63 @@ describe("identity-change requests point at the config field, not forget_memory"
203203
expect(text).toContain("Custom Prompt");
204204
});
205205
});
206+
207+
describe("cache-friendly ordering (#66)", () => {
208+
// Prompt caching is a prefix match: anything after the first per-request
209+
// byte is uncacheable. The home layout and device cheat sheet change on
210+
// rescan (~30 min), not per request, so they must sit BEFORE the
211+
// timestamps and retrieved facts, not after.
212+
const LAYOUT = "## Home Layout:\n- Ground floor: kitchen";
213+
const DEVICES = "## Device Capabilities:\n- light.kitchen: rgbw";
214+
215+
it("puts the home description in its own cached block, ahead of the volatile one", () => {
216+
const blocks = buildSystemPrompt(
217+
["fact1"], false, undefined, DEVICES, LAYOUT
218+
) as TextBlock[];
219+
220+
expect(blocks).toHaveLength(3);
221+
// Home description: cached, no per-request content
222+
expect(blocks[1]).toMatchObject({ cache_control: { type: "ephemeral" } });
223+
expect(blocks[1].text).toContain("Home Layout");
224+
expect(blocks[1].text).toContain("Device Capabilities");
225+
expect(blocks[1].text).not.toContain("Date/Time");
226+
// Volatile block: timestamps + facts, NOT cached
227+
expect(blocks[2]).not.toHaveProperty("cache_control");
228+
expect(blocks[2].text).toContain("Date/Time");
229+
expect(blocks[2].text).toContain("fact1");
230+
expect(blocks[2].text).not.toContain("Home Layout");
231+
});
232+
233+
it("emits no empty home block when there is no layout or cheat sheet", () => {
234+
const blocks = buildSystemPrompt(["fact1"]) as TextBlock[];
235+
expect(blocks).toHaveLength(2);
236+
expect(blocks.every((b) => b.text.length > 0)).toBe(true);
237+
});
238+
239+
it("keeps the plain-text prompt volatile-last for automatic prefix caching", () => {
240+
const text = buildSystemPromptText(
241+
["fact1"], false, undefined, DEVICES, LAYOUT
242+
);
243+
const timestamp = text.indexOf("Date/Time");
244+
expect(timestamp).toBeGreaterThan(text.indexOf("Home Layout"));
245+
expect(timestamp).toBeGreaterThan(text.indexOf("Device Capabilities"));
246+
// Facts are retrieved per message — they stay in the volatile tail too.
247+
expect(text.indexOf("fact1")).toBeGreaterThan(text.indexOf("Device Capabilities"));
248+
});
249+
250+
it("keeps every section present after the reorder", () => {
251+
const text = buildSystemPromptText(
252+
["fact1"], false, undefined, DEVICES, LAYOUT, "en"
253+
);
254+
for (const marker of [
255+
"You are Nives",
256+
"Home Layout",
257+
"Device Capabilities",
258+
"## Current Context:",
259+
"Interface language",
260+
"fact1",
261+
]) {
262+
expect(text).toContain(marker);
263+
}
264+
});
265+
});

server/src/home-mind-server/src/llm/prompts.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -326,30 +326,43 @@ export function buildSystemPrompt(
326326
? `\n- Interface language: ${language} — the default for your reply ONLY when the user's own words don't clearly indicate a language.`
327327
: "";
328328

329-
// Dynamic content that changes per request
330329
const layoutSection = homeLayout ? `\n\n${homeLayout}` : "";
331330
const deviceSection = deviceCheatSheet ? `\n\n${deviceCheatSheet}` : "";
332-
const dynamicContent = `
331+
332+
// Prompt caching is a prefix match, so content is ordered least-volatile
333+
// first: identity + instructions (changes on release/custom prompt), then
334+
// the home description (changes on rescan, ~30 min), then the genuinely
335+
// per-request parts — timestamps and retrieved facts (#66). The home
336+
// description gets its own cache breakpoint: a rescan invalidates it
337+
// without touching the instruction block's cache entry.
338+
const volatileContent = `
333339
## Current Context:
334340
- Date/Time: ${dateTimeStr}
335341
- ISO Timestamp (now, UTC): ${isoTimestamp}
336342
- Local midnight today (UTC): ${localMidnightIso} ← use this as start_time for "today" history queries, NOT 00:00:00Z${languageLine}
337343
338344
## What You Remember About This User:
339-
${factsText}${layoutSection}${deviceSection}`;
345+
${factsText}`;
340346

341-
// Build content blocks: identity + instructions (cached) + dynamic
342347
const blocks: Anthropic.TextBlockParam[] = [
343348
{
344349
type: "text" as const,
345350
text: identity + instructions,
346351
cache_control: { type: "ephemeral" as const },
347352
},
348-
{
349-
type: "text" as const,
350-
text: dynamicContent,
351-
},
352353
];
354+
const homeDescription = `${layoutSection}${deviceSection}`;
355+
if (homeDescription) {
356+
blocks.push({
357+
type: "text" as const,
358+
text: homeDescription,
359+
cache_control: { type: "ephemeral" as const },
360+
});
361+
}
362+
blocks.push({
363+
type: "text" as const,
364+
text: volatileContent,
365+
});
353366

354367
return blocks;
355368
}
@@ -389,13 +402,16 @@ export function buildSystemPromptText(
389402
const layoutSection = homeLayout ? `\n\n${homeLayout}` : "";
390403
const deviceSection = deviceCheatSheet ? `\n\n${deviceCheatSheet}` : "";
391404

392-
return `${identity}${instructions}
405+
// Volatile-last, same reasoning as buildSystemPrompt (#66): providers with
406+
// automatic prefix caching can then reuse everything up to the home
407+
// description, instead of missing from the first timestamp onward.
408+
return `${identity}${instructions}${layoutSection}${deviceSection}
393409
394410
## Current Context:
395411
- Date/Time: ${dateTimeStr}
396412
- ISO Timestamp (now, UTC): ${isoTimestamp}
397413
- Local midnight today (UTC): ${localMidnightIso} ← use this as start_time for "today" history queries, NOT 00:00:00Z${languageLine}
398414
399415
## What You Remember About This User:
400-
${factsText}${layoutSection}${deviceSection}`;
416+
${factsText}`;
401417
}

0 commit comments

Comments
 (0)