melchizedek-agents 1.15.0 → 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ Consumers of the package read this file; it records changes to the
4
4
  **published API surface** (the exports map in `package.json`, the bins,
5
5
  the starter pack and the templates), not the repo's full history.
6
6
 
7
+ ## 1.15.2 — 2026-11-06
8
+
9
+ **Fixed: a turn's usage and cost left out `generate_image`.** The tool called
10
+ Gemini's image model outside the tracer, so its tokens never reached the
11
+ turn's usage or the ledger's `cost_usd`. The call is now an `llm.request`
12
+ through `traceLlmGeneration`, like every other model call: its tokens count in
13
+ the turn's usage, it counts against `max_steps`, and a cancelled turn stops it
14
+ before the request. Migration 0024 adds the price row for
15
+ `gemini-3.1-flash-image-preview` ($0.50 in, $60.00 out at the image rate,
16
+ $3.00 thinking, from Google's page for `gemini-3.1-flash-image`;
17
+ `verified_at` NULL, since the preview id is not listed). A typical image costs
18
+ about $0.08. Apply it with `npx melchizedek-db apply` before upgrading; without it,
19
+ a turn that made an image prices NULL.
20
+
21
+ ## 1.15.1 — 2026-11-06
22
+
23
+ **Fixed: a conversation stuck on Anthropic after an approval** (ADR 0155).
24
+ When one model step made several tool calls and one of them required
25
+ approval, the calls beside it ran but their results were never stored. After
26
+ the approval the history held the step's calls with only the approved one
27
+ answered, and Anthropic refused every later request with `tool_use ids were
28
+ found without tool_result blocks immediately after`. The loop now stores the
29
+ other calls' results before the approval request. The request builder also
30
+ gives any call the history leaves unanswered an error result: an approval
31
+ the person moved on from by typing a new message, a run that ended between a call and its
32
+ result, and sessions stored before this release, which recover without
33
+ editing. Gemini, which accepted the unanswered calls, sees the same history
34
+ plus those results.
35
+
7
36
  ## 1.15.0 — 2026-11-02
8
37
 
9
38
  **Breaking — read before upgrading**
@@ -0,0 +1,28 @@
1
+ -- ============================================================================
2
+ -- 0024_image_model_pricing — a price row for the image model generate_image
3
+ -- calls (extends ADR 0136)
4
+ -- ============================================================================
5
+ -- Rows only, in 0017's column layout (USD per million tokens). generate_image
6
+ -- (lib/tools/generateImageTool.ts) now traces its call as an llm.request, so
7
+ -- the turn's usage holds gemini-3.1-flash-image-preview; without a row the
8
+ -- whole turn prices NULL.
9
+ --
10
+ -- gemini-3.1-flash-image-preview: Google's Gemini API pricing page
11
+ -- (aistudio.google.com/docs/pricing, checked 2026-11-06) lists the model as
12
+ -- gemini-3.1-flash-image, standard tier: $0.50 in, $3.00 text and thinking
13
+ -- out, $60.00 image out. The preview id is not on the page, so verified_at
14
+ -- stays NULL (0015's convention). A flat row has one output rate: it holds
15
+ -- the image rate, since nearly all of the call's output is the image, so the
16
+ -- few text tokens beside it are overcounted. Thinking bills at $3.00.
17
+ --
18
+ -- New rows never overwrite a row that exists: an operator's own rates
19
+ -- survive. Nothing secret, nothing personal. Idempotent; safe to re-run.
20
+ -- ============================================================================
21
+
22
+ INSERT INTO model_pricing (model, input_per_mtok, output_per_mtok, thinking_per_mtok, cache_read_per_mtok, cache_write_per_mtok, verified_at, source, note) VALUES
23
+ ('gemini-3.1-flash-image-preview', 0.50, 60.00, 3.00, NULL, NULL, NULL, 'aistudio.google.com/docs/pricing (as gemini-3.1-flash-image), checked 2026-11-06', 'output at the image rate; text output ($3.00) is overcounted; preview id not on the page')
24
+ ON CONFLICT (model) DO NOTHING;
25
+
26
+ INSERT INTO melchizedek_schema_version (version, name)
27
+ VALUES (24, '0024_image_model_pricing')
28
+ ON CONFLICT (version) DO NOTHING;
@@ -47,8 +47,11 @@
47
47
  * mergeParallelFunctionResponseEvents). ADK runs the calls one after
48
48
  * another and lets a call read the state an earlier call of the same
49
49
  * step wrote; here each call reads the state as the step left it.
50
- * 3. An approval request ends the run: the response is NOT stored; in its
51
- * place the event ADK stores, a call to `adk_request_confirmation`
50
+ * 3. An approval request ends the run: the gated calls' answers are NOT
51
+ * stored. The step's other calls ran, and their answers are stored
52
+ * first, as their own event with no actions (answeredBesideGates): a
53
+ * provider other than Gemini refuses a call with no result. Then, in
54
+ * the gated calls' place, the event ADK stores, a call to `adk_request_confirmation`
52
55
  * (args: the original call and the confirmation; an `adk-` id listed in
53
56
  * longRunningToolIds), carrying the response's actions. The answer
54
57
  * resumes it: before each step, lib/runtime/native/interrupts.ts runs
@@ -47,8 +47,11 @@
47
47
  * mergeParallelFunctionResponseEvents). ADK runs the calls one after
48
48
  * another and lets a call read the state an earlier call of the same
49
49
  * step wrote; here each call reads the state as the step left it.
50
- * 3. An approval request ends the run: the response is NOT stored; in its
51
- * place the event ADK stores, a call to `adk_request_confirmation`
50
+ * 3. An approval request ends the run: the gated calls' answers are NOT
51
+ * stored. The step's other calls ran, and their answers are stored
52
+ * first, as their own event with no actions (answeredBesideGates): a
53
+ * provider other than Gemini refuses a call with no result. Then, in
54
+ * the gated calls' place, the event ADK stores, a call to `adk_request_confirmation`
52
55
  * (args: the original call and the confirmation; an `adk-` id listed in
53
56
  * longRunningToolIds), carrying the response's actions. The answer
54
57
  * resumes it: before each step, lib/runtime/native/interrupts.ts runs
@@ -515,6 +518,25 @@ function confirmationEvent(scope, modelEvent, response) {
515
518
  longRunningToolIds: parts.map((p) => p.functionCall?.id),
516
519
  });
517
520
  }
521
+ /**
522
+ * The answers of a step that paused on an approval, beside its gated calls:
523
+ * the response's parts without the gated calls' stand-ins, and without the
524
+ * actions, which the confirmation event carries. Undefined when every call
525
+ * waits on the approval. Every provider but Gemini refuses a call with no
526
+ * result, so a call that already ran keeps its result through the pause.
527
+ */
528
+ function answeredBesideGates(scope, response) {
529
+ const gated = new Set(Object.keys(response.actions.requestedToolConfirmations ?? {}));
530
+ const parts = (response.content?.parts ?? []).filter((p) => !(p.functionResponse?.id && gated.has(p.functionResponse.id)));
531
+ if (!parts.some((p) => p.functionResponse))
532
+ return undefined;
533
+ return createTurnEvent({
534
+ invocationId: scope.ctx.invocationId,
535
+ author: scope.agent.name,
536
+ branch: scope.ctx.branch,
537
+ content: { parts, role: response.content?.role ?? 'user' },
538
+ });
539
+ }
518
540
  /**
519
541
  * ADK's request-confirmation processor, before a step: the pinned calls the
520
542
  * latest user message approves or refuses run with the answer in their
@@ -801,6 +823,9 @@ async function* agentLoop(agent, ctx) {
801
823
  const authIds = auth ? [...(lastEvent.longRunningToolIds ?? [])] : [];
802
824
  const confirmation = confirmationEvent(scope, modelEvent, response);
803
825
  if (confirmation) {
826
+ const answered = answeredBesideGates(scope, response);
827
+ if (answered)
828
+ yield await store(answered);
804
829
  lastEvent = await store(confirmation);
805
830
  yield lastEvent;
806
831
  return { reason: 'paused', steps, lastEvent, pending: [...(lastEvent.longRunningToolIds ?? []), ...delegated] };
@@ -69,7 +69,7 @@
69
69
  * yielded by a toolset or passed in `extraTools`, is refused, naming 1.0.0
70
70
  * and defineTool, as registerTool refuses one (ADR 0107).
71
71
  */
72
- import type { ModelRequest } from '../../models/contract.ts';
72
+ import type { Message, ModelRequest } from '../../models/contract.ts';
73
73
  import type { MemoryService } from '../memoryService.ts';
74
74
  import type { TurnContent, TurnEvent } from '../events.ts';
75
75
  import type { Session } from '../sessions.ts';
@@ -230,4 +230,17 @@ export declare function isToolset(value: unknown): value is {
230
230
  * request: a required state key that is absent, a
231
231
  * Gemini-only tool on another model, a config field LlmAgent refuses.
232
232
  */
233
+ /** What a call with no stored result reads as: it never answered. */
234
+ export declare const UNANSWERED_CALL = "This call has no result: it never ran, or the run ended before it answered.";
235
+ /**
236
+ * Every assistant message's tool calls answered in the message right after
237
+ * it, as the contract's ToolMessage holds them (one result per call).
238
+ * A call the history leaves unanswered (an approval the person moved on
239
+ * from, a run that ended between a call and its result) gets an error
240
+ * result saying it never answered. Anthropic and OpenAI refuse a request
241
+ * that leaves a call unanswered, and every later request replays it, so
242
+ * without this one such call ends the conversation. Results already there
243
+ * stay as they are, in their order; the stand-ins follow them.
244
+ */
245
+ export declare function answerEveryCall(messages: Message[]): Message[];
233
246
  export declare function buildModelRequest(agent: NativeAgent, ctx: RequestContext): Promise<BuiltRequest>;
@@ -448,6 +448,45 @@ function placementOf(native, model, configTools) {
448
448
  * request: a required state key that is absent, a
449
449
  * Gemini-only tool on another model, a config field LlmAgent refuses.
450
450
  */
451
+ /** What a call with no stored result reads as: it never answered. */
452
+ export const UNANSWERED_CALL = 'This call has no result: it never ran, or the run ended before it answered.';
453
+ /**
454
+ * Every assistant message's tool calls answered in the message right after
455
+ * it, as the contract's ToolMessage holds them (one result per call).
456
+ * A call the history leaves unanswered (an approval the person moved on
457
+ * from, a run that ended between a call and its result) gets an error
458
+ * result saying it never answered. Anthropic and OpenAI refuse a request
459
+ * that leaves a call unanswered, and every later request replays it, so
460
+ * without this one such call ends the conversation. Results already there
461
+ * stay as they are, in their order; the stand-ins follow them.
462
+ */
463
+ export function answerEveryCall(messages) {
464
+ const out = [];
465
+ for (let i = 0; i < messages.length; i++) {
466
+ const message = messages[i];
467
+ out.push(message);
468
+ if (message.role !== 'assistant')
469
+ continue;
470
+ const calls = message.parts.filter((p) => p.type === 'toolCall');
471
+ if (calls.length === 0)
472
+ continue;
473
+ const next = messages[i + 1];
474
+ const results = next?.role === 'tool' ? next.parts : [];
475
+ const answered = new Set(results.map((r) => r.id));
476
+ const missing = calls
477
+ .filter((c) => !answered.has(c.id))
478
+ .map((c) => ({ type: 'toolResult', id: c.id, name: c.name, result: UNANSWERED_CALL, isError: true }));
479
+ if (missing.length === 0)
480
+ continue;
481
+ if (next?.role === 'tool') {
482
+ out.push({ role: 'tool', parts: [...next.parts, ...missing] });
483
+ i += 1;
484
+ }
485
+ else
486
+ out.push({ role: 'tool', parts: missing });
487
+ }
488
+ return out;
489
+ }
451
490
  export async function buildModelRequest(agent, ctx) {
452
491
  const model = agent.model;
453
492
  const cfg = { ...(agent.generateContentConfig ?? {}) };
@@ -621,7 +660,7 @@ export async function buildModelRequest(agent, ctx) {
621
660
  const reasoning = reasoningOf(cfg);
622
661
  const sampling = samplingOf(cfg);
623
662
  const systemPrompt = systemText(system);
624
- const messages = contentsToMessages(contents).messages;
663
+ const messages = answerEveryCall(contentsToMessages(contents).messages);
625
664
  const request = {
626
665
  model,
627
666
  ...(systemPrompt !== undefined ? { system: systemPrompt } : {}),
@@ -14,7 +14,13 @@
14
14
  * The contract is an own Tool (lib/tools/tool.ts); `generateImageTool` is the
15
15
  * own Tool the registry lists, made from it by asTool.
16
16
  */
17
+ import { GoogleGenAI } from '@google/genai';
17
18
  import { z } from 'zod';
19
+ export declare const IMAGE_MODEL = "gemini-3.1-flash-image-preview";
20
+ /** Makes the Gemini client; a test swaps it for a stub. */
21
+ export declare const imageClient: {
22
+ create: (apiKey: string) => Pick<GoogleGenAI, 'models'>;
23
+ };
18
24
  export declare const generateImageContract: import("./toolContract.ts").DefinedTool<z.ZodObject<{
19
25
  prompt: z.ZodString;
20
26
  style: z.ZodOptional<z.ZodString>;
@@ -21,7 +21,12 @@ import { z } from 'zod';
21
21
  import { asTool, defineTool } from './toolContract.js';
22
22
  import { outputsDir } from '../paths.js';
23
23
  import { credentialValue } from '../models/credentialSource.js';
24
- const IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
24
+ import { traceLlmGeneration } from '../observability/tracer.js';
25
+ export const IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
26
+ /** Makes the Gemini client; a test swaps it for a stub. */
27
+ export const imageClient = {
28
+ create: (apiKey) => new GoogleGenAI({ apiKey }),
29
+ };
25
30
  export const generateImageContract = defineTool({
26
31
  name: 'generate_image',
27
32
  description: 'Generates a real image from a structured payload and saves it to the outputs/ directory. ' +
@@ -42,7 +47,7 @@ export const generateImageContract = defineTool({
42
47
  }
43
48
  // Sizes and which options are set, never their values: the prompt carries the person's words.
44
49
  console.log(`[ImageTool] Parameters - prompt ${prompt.length} chars; set: ${[style && 'style', aspect_ratio && 'aspect_ratio', color_palette && 'color_palette'].filter(Boolean).join(', ') || 'none'}`);
45
- const ai = new GoogleGenAI({ apiKey });
50
+ const ai = imageClient.create(apiKey);
46
51
  // Build a rich prompt that bakes in the structured parameters.
47
52
  const parts = [prompt];
48
53
  if (style)
@@ -54,13 +59,28 @@ export const generateImageContract = defineTool({
54
59
  const fullPrompt = parts.join('. ');
55
60
  try {
56
61
  console.log(`[ImageTool] Sending generateContent request to Gemini API (Model: ${IMAGE_MODEL})...`);
57
- const response = await ai.models.generateContent({
58
- model: IMAGE_MODEL,
59
- contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
60
- config: {
61
- responseModalities: ['IMAGE', 'TEXT'],
62
- },
63
- });
62
+ // Traced as one llm.request like every other model call, so the image
63
+ // model's tokens reach the turn's usage and the ledger prices them
64
+ // (model_pricing); it also counts against the turn's step budget and stops on cancel.
65
+ let response;
66
+ async function* call() {
67
+ response = await ai.models.generateContent({
68
+ model: IMAGE_MODEL,
69
+ contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
70
+ config: {
71
+ responseModalities: ['IMAGE', 'TEXT'],
72
+ },
73
+ });
74
+ yield { usageMetadata: response.usageMetadata };
75
+ }
76
+ const request = () => ({ model: IMAGE_MODEL, messages: [{ role: 'user', parts: [{ type: 'text', text: fullPrompt }] }] });
77
+ for await (const r of traceLlmGeneration({ provider: 'gemini', model: IMAGE_MODEL, request }, call())) {
78
+ // A refused call (the step budget, a cancelled turn) never reaches the API.
79
+ if (r.errorCode)
80
+ return `[generate_image ERROR] Model: ${IMAGE_MODEL} - ${r.errorMessage || r.errorCode}`;
81
+ }
82
+ if (!response)
83
+ return `No image data returned. Model used: ${IMAGE_MODEL}`;
64
84
  console.log("[ImageTool] Received response from Gemini API.");
65
85
  const candidates = response.candidates ?? [];
66
86
  console.log(`[ImageTool] Candidates found: ${candidates.length}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melchizedek-agents",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "A multi-model, multi-agent orchestration framework — YAML-defined syndicates on the engine's own agent loop, Supabase or Postgres session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
5
5
  "type": "module",
6
6
  "license": "MIT",