Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ The variables set the default: omit both namespace and profile to use `default/a

Writes keep the host's session ID wherever they go, and the model can't pass `sessionId` as an argument. A default should be a starting point, not a fence.

Give `memory_remember` up to four `copies` and one call stores the same content and session in each profile. Pasting the same finding twice can finally retire. The main record lands first, but the writes aren't atomic. If a copy fails, the error shows what landed and which profiles still need a retry.

Don't have an API token? The tools can also use `cf` CLI authentication. Run `cf auth login` and omit `CLOUDFLARE_API_TOKEN`. Keep the account ID when the login reaches more than one account.

`CLOUDFLARE_ACCOUNT_ID` counts too, so the one your shell already exports for wrangler just works. And when Cloudflare refuses a cf token mid-session, the next call goes back to cf for a fresh one instead of sulking until it expires.
Expand Down
7 changes: 6 additions & 1 deletion extensions/shared/memory-tool-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const RECALL_ANSWER_TRUST_GUIDANCE =
export const MAX_TOOL_MESSAGE_CHARACTERS = 32_768;
export const MAX_TOOL_MEMORY_CONTENT_CHARACTERS = 32_768;
export const MAX_TOOL_INGEST_MESSAGES = 500;
/** Extra profiles one memory_remember call may copy into; each copy is one more write. */
export const MAX_TOOL_MEMORY_COPIES = 4;
/**
* Cap on how many memories one call may read with full content.
*
Expand Down Expand Up @@ -104,7 +106,7 @@ export const memoryToolMeta = {
},
memory_remember: {
label: "Remember",
description: `Store one durable memory in a Cloudflare Agent Memory profile. The service writes the content verbatim and assigns the memory type itself. A fact or instruction on the topic of an existing one supersedes it: the older memory leaves memory_list and memory_recall as soon as the newer one is stored, while memory_get still reads it by id, and remembering the same content twice creates a second record that supersedes the first. Events accumulate instead. ${MEMORY_SUMMARY_TRUST_GUIDANCE}`,
description: `Store one durable memory in a Cloudflare Agent Memory profile. The service writes the content verbatim and assigns the memory type itself. A fact or instruction on the topic of an existing one supersedes it: the older memory leaves memory_list and memory_recall as soon as the newer one is stored, while memory_get still reads it by id, and remembering the same content twice creates a second record that supersedes the first. Events accumulate instead. With copies, the same content and session land in each listed profile as well, in one call instead of one call per profile: the first profile is written first, the copies only after it is stored, and each record keeps its own id. ${MEMORY_SUMMARY_TRUST_GUIDANCE}`,
},
memory_supersede: {
label: "Supersede Memory",
Expand Down Expand Up @@ -180,6 +182,9 @@ export const memoryFieldDescriptions = {
cursor:
"Cursor copied from the previous response. A response without a cursor was the last page.",
rememberContent: `Memory content to store. The service writes it verbatim, assigns the memory type itself, and rejects content over ${MAX_TOOL_MEMORY_CONTENT_CHARACTERS} UTF-8 bytes, the same cap as one ingested message.`,
rememberCopies: `Up to ${MAX_TOOL_MEMORY_COPIES} more namespace and profile pairs that get the same content and session, in this namespace or any other, for example a project profile next to a shared one. Writes are not atomic: a failed copy names the records already stored and each profile still missing, so retry only those. Each pair must differ from the others and from the main profile.`,
copyNamespace: "Namespace of the copy.",
copyProfile: "Profile inside the copy's namespace. The first write creates it.",
messageRole: "Who produced the message.",
messageContent: "Message text.",
messageTimestamp:
Expand Down
84 changes: 55 additions & 29 deletions extensions/shared/memory-tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MAX_TOOL_INGEST_MESSAGES,
MAX_TOOL_LIST_LIMIT,
MAX_TOOL_MEMORY_CONTENT_CHARACTERS,
MAX_TOOL_MEMORY_COPIES,
MAX_TOOL_MESSAGE_CHARACTERS,
MAX_TOOL_NAMESPACE_CHARACTERS,
MAX_TOOL_PROFILE_CHARACTERS,
Expand All @@ -20,20 +21,38 @@ import type { ProfileParams } from "./memory-tools.types.ts";
/** Parameter schemas of the memory tools. OMP rewrites a bare `typebox` import, so none here. */
/** `defineTool` refuses open objects: a misspelled key would change the call silently. */
const closed = { additionalProperties: false } as const;
const namespaceName = Type.String({
description: memoryFieldDescriptions.namespace,
minLength: 1,
maxLength: MAX_TOOL_NAMESPACE_CHARACTERS,
pattern: MEMORY_NAME_PATTERN,
});
const profileParameters = {
namespace: namespaceName,
profile: Type.String({
description: memoryFieldDescriptions.profile,
/**
* One name rule for every namespace field, whatever its description says.
*
* @param description - What the field means where it sits.
* @returns {TString} The namespace schema.
*/
function namespaceString(description: string) {
return Type.String({
description,
minLength: 1,
maxLength: MAX_TOOL_NAMESPACE_CHARACTERS,
pattern: MEMORY_NAME_PATTERN,
});
}
/**
* One name rule for every profile field, whatever its description says.
*
* @param description - What the field means where it sits.
* @returns {TString} The profile schema.
*/
function profileString(description: string) {
return Type.String({
description,
minLength: 1,
maxLength: MAX_TOOL_PROFILE_CHARACTERS,
pattern: MEMORY_NAME_PATTERN,
}),
});
}
const namespaceName = namespaceString(memoryFieldDescriptions.namespace);
const profileParameters = {
namespace: namespaceName,
profile: profileString(memoryFieldDescriptions.profile),
};
const memoryId = Type.String({
description: memoryFieldDescriptions.memoryId,
Expand Down Expand Up @@ -107,6 +126,22 @@ export const rememberParameters = Type.Object(
maxLength: MAX_TOOL_SESSION_ID_CHARACTERS,
}),
),
copies: Type.Optional(
Type.Array(
Type.Object(
{
namespace: namespaceString(memoryFieldDescriptions.copyNamespace),
profile: profileString(memoryFieldDescriptions.copyProfile),
},
closed,
),
{
description: memoryFieldDescriptions.rememberCopies,
minItems: 1,
maxItems: MAX_TOOL_MEMORY_COPIES,
},
),
),
},
closed,
);
Expand Down Expand Up @@ -316,22 +351,8 @@ export const profileOnlyParameters = Type.Object(profileParameters, closed);
*/
function boundProfileParameters(target: string) {
return {
namespace: Type.Optional(
Type.String({
description: memoryFieldDescriptions.boundNamespace(target),
minLength: 1,
maxLength: MAX_TOOL_NAMESPACE_CHARACTERS,
pattern: MEMORY_NAME_PATTERN,
}),
),
profile: Type.Optional(
Type.String({
description: memoryFieldDescriptions.boundProfile(target),
minLength: 1,
maxLength: MAX_TOOL_PROFILE_CHARACTERS,
pattern: MEMORY_NAME_PATTERN,
}),
),
namespace: Type.Optional(namespaceString(memoryFieldDescriptions.boundNamespace(target))),
profile: Type.Optional(profileString(memoryFieldDescriptions.boundProfile(target))),
};
}

Expand Down Expand Up @@ -401,12 +422,17 @@ function rebuildOperationalSchemas(
}

/**
* The schemas of a surface locked to one profile: no scope fields, and no `sessionId` on a write.
* The schemas of a surface locked to one profile: no scope fields, no `sessionId` on a write,
* and no `copies`, which would carry a write out of that profile.
*
* @returns {Record<OperationalSchemaName, TObject>} The schemas the model sees.
*/
export function createLockedMemoryToolSchemas(): Record<OperationalSchemaName, TObject> {
return rebuildOperationalSchemas(scopelessSchema);
return rebuildOperationalSchemas((name) =>
name === "rememberParameters"
? Type.Omit(scopelessSchema(name), ["copies"], closed)
: scopelessSchema(name),
);
}

/**
Expand Down
118 changes: 114 additions & 4 deletions extensions/shared/memory-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import type { CredentialSource } from "@agntn/credentials";
import { type CloudflareCredentials, getCloudflareCredentials } from "./cloudflare-credentials.ts";
import { isIngestedMessageId } from "./memory-ids.ts";
import { validateMemoryScope } from "./memory-scope.ts";
import { singleLineToolText, TOOL_TEXT_CONTROL_PATTERN } from "./terminal-text.ts";
import { MAX_TOOL_ERROR_CHAIN_DEPTH, toolErrorMessage } from "./tool-errors.ts";
import {
Expand All @@ -22,6 +23,7 @@ import {
MAX_TOOL_RECALL_VISIBILITY_TIMEOUT_SECONDS,
SERVICE_GENERATED_UNTRUSTED,
MAX_TOOL_EXPAND,
MAX_TOOL_MEMORY_COPIES,
DEFAULT_TOOL_RECALL_CANDIDATES,
RECALL_RELEVANCE_FLOOR,
RECALL_RELEVANCE_FLOOR_TEXT,
Expand Down Expand Up @@ -952,13 +954,121 @@ function emptyCascade(removed: RemovedCount): boolean {
return removed?.count === 0 && !removed.atLeast;
}

/**
* Check the copies before the first write, since the schema guards only tool callers.
*
* @param params - The remember call, main profile included.
* @param copies - The extra profiles it names.
* @throws {Error} On a bad count, a bad name, or a profile named twice.
*/
function assertCopies(params: Readonly<ProfileParams>, copies: readonly ProfileParams[]): void {
const count = Array.isArray(copies as unknown) ? copies.length : 0;
if (count < 1 || count > MAX_TOOL_MEMORY_COPIES) {
throw new Error(`Copies must be a list of 1 to ${MAX_TOOL_MEMORY_COPIES} profiles`);
}
const targets = new Set([memoryTarget(params)]);
for (const copy of copies) {
const target = memoryTarget(validateMemoryScope(copy.namespace, copy.profile, "Each copy"));
if (targets.has(target)) {
throw new Error(`Copies name ${quoteToolText(target)} twice; each profile gets one record`);
}
targets.add(target);
}
}

interface StoredCopy {
readonly target: string;
readonly memory: Memory;
}

/**
* One line per stored record, the shared content only under the first.
*
* @param stored - Every record the call wrote, main profile first.
* @returns {string} The result text.
*/
function storedCopiesText(stored: readonly StoredCopy[]): string {
const shared = stored[0]?.memory.content;
const records = stored.map(({ target, memory }, index) => {
if (index === 0 || memory.content !== shared) return { target, ...memory };
const { content: _content, ...rest } = memory;
return { target, ...rest };
});
return `Stored the same memory in ${stored.length} profiles. A copy whose content matches the first record leaves it out.\n${recordLines(records)}`;
}

/**
* Name what landed and what did not, so a retry goes only to the missing profiles.
*
* @param stored - Records written before or beside the failed copies.
* @param failed - Each failed copy with its reason.
* @returns {Error} One error for the whole call.
*/
function failedCopiesError(
stored: readonly StoredCopy[],
failed: readonly { readonly target: string; readonly reason: unknown }[],
): Error {
const storedText = stored
.map(({ target, memory }) => `${target} ${quoteToolText(memory.id)}`)
.join(", ");
const failedText = failed
.map(
({ target, reason }) => `${target}: ${withCredentialStep(toolErrorMessage(reason), reason)}`,
)
.join("; ");
return new Error(
`Stored memory in ${storedText}, but ${failed.length} of ${stored.length + failed.length - 1} copies failed. ${failedText}. Check memory_list in each failed profile before retrying, since a failed write may have landed anyway, and retry only the profiles that lack the memory.`,
);
}

/**
* Write the same memory into every copy at once, after the main record is stored.
*
* @param main - The record already in the main profile.
* @param copies - Profiles that get the same content.
* @param write - Content and session of the main record.
* @param options - Abort signal shared by every write.
* @returns {Promise<MemoryToolResult>} One line per record, or an error naming the gaps.
*/
async function rememberCopies(
main: StoredCopy,
copies: readonly ProfileParams[],
write: Readonly<{ content: string; sessionId?: string }>,
options: AbortOptions,
): Promise<MemoryToolResult> {
const settled = await Promise.allSettled(
copies.map(async (copy) =>
requireCapability(await getProfile(copy), "remember")(write, options),
),
);
const stored: StoredCopy[] = [main];
const failed: { target: string; reason: unknown }[] = [];
settled.forEach((outcome, index) => {
const target = memoryTarget(copies[index] as ProfileParams);
if (outcome.status === "fulfilled") stored.push({ target, memory: outcome.value });
else failed.push({ target, reason: outcome.reason });
});
if (failed.length > 0) throw failedCopiesError(stored, failed);
return {
content: [{ type: "text", text: storedCopiesText(stored) }],
details: { records: stored.map(({ target, memory }) => ({ target, ...memory })) },
};
}

export const memoryToolExecutors = {
async remember(params: RememberParams, options: AbortOptions = {}): Promise<MemoryToolResult> {
if (params.copies !== undefined) assertCopies(params, params.copies);
const write = { content: params.content, sessionId: optionalString(params.sessionId) };
const profile = await getProfile(params);
const memory = await requireCapability(profile, "remember")(
{ content: params.content, sessionId: optionalString(params.sessionId) },
options,
);
const memory = await requireCapability(profile, "remember")(write, options);
if (params.copies !== undefined) {
return rememberCopies(
{ target: memoryTarget(params), memory },
params.copies,
write,
options,
);
}
return {
content: [{ type: "text", text: json(withSummaryTrust(memory)) }],
details: memory as unknown as Record<string, unknown>,
Expand Down
2 changes: 2 additions & 0 deletions extensions/shared/memory-tools.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface ProfileListParams extends NamespaceParams {
export interface RememberParams extends ProfileParams {
content: string;
sessionId?: string;
/** More profiles that get the same content and session, written after the main one. */
copies?: readonly ProfileParams[];
}

export interface SupersedeParams extends ProfileParams {
Expand Down
1 change: 1 addition & 0 deletions extensions/shared/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ function callMeta(
const meta: string[] = [];
if (subject !== undefined && target !== undefined) meta.push(field(target, META_WIDTH));
if (Array.isArray(record.messages)) meta.push(`messages ${record.messages.length}`);
if (Array.isArray(record.copies)) meta.push(`copies ${record.copies.length}`);
for (const [key, metaLabel] of Object.entries(CALL_META_LABELS)) {
const value = scalarText(record[key]);
if (value !== undefined) meta.push(`${metaLabel} ${field(value, META_WIDTH)}`);
Expand Down
15 changes: 15 additions & 0 deletions test/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,21 @@ describe("@agntn/memory/ai locked to one profile", () => {
for (const tool of writes) {
expect(Object.keys(tool.input.properties), tool.name).not.toContain("sessionId");
}
const remember = writes.find((tool) => tool.name === "memory_remember");
expect(Object.keys(remember!.input.properties)).not.toContain("copies");
});

it("refuses copies that would carry a write out of the locked profile", async () => {
const requests = stubService();
const tools = createMemoryAiTools(locked);

await expect(
run(tools.memory_remember, {
content: "prefers short answers",
copies: [{ namespace: "support", profile: "user-43" }],
}),
).rejects.toThrow('unknown property "copies"');
expect(requests).toEqual([]);
});

it("refuses a call that names another profile before any request", async () => {
Expand Down
12 changes: 6 additions & 6 deletions test/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,23 +200,23 @@ describe("memory extensions", () => {
await expect(
remember.execute(
"call-0",
{ profile: "gsmg", content: "half" },
{ profile: "project", content: "half" },
signal,
undefined,
context,
),
).rejects.toThrow("pass namespace and profile together, or neither for default/oritwoen");
await remember.execute(
"call-1",
{ namespace: "puzzles", profile: "gsmg", content: "project fact" },
{ namespace: "research", profile: "project", content: "project fact" },
signal,
undefined,
context,
);
expect(execute).toHaveBeenLastCalledWith(
{
namespace: "puzzles",
profile: "gsmg",
namespace: "research",
profile: "project",
content: "project fact",
sessionId: expect.stringMatching(new RegExp(`^${harness}-movie-enigma-[0-9a-f]{16}$`)),
},
Expand Down Expand Up @@ -567,11 +567,11 @@ describe("memory tool renderers", () => {

for (const tools of [piTools(), ompTools()]) {
const { text } = tool(tools, "memory_recall").renderCall!(
{ namespace: "puzzles", profile: "gsmg", query: "elsewhere" },
{ namespace: "research", profile: "project", query: "elsewhere" },
{},
{},
);
expect(text).toContain("puzzles/gsmg");
expect(text).toContain("research/project");
expect(text).not.toContain("oritwoen");
}
});
Expand Down
Loading
Loading