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
26 changes: 23 additions & 3 deletions ai/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ function countImages(messages: AgentMessage[]): number {
return n;
}

/**
* The history with the server's map-state block kept at the end of the message it was
* added to — the last one, the message just sent — so the next request sends that message
* as the model saw it. A message that differs from the one a thinking block was made after
* costs the model that block and every later one (the 5.5 models check), and before this
* the block was dropped from every message, which lost the reasoning of every round.
*/
export function keepState(messages: AgentMessage[], state: string | undefined): AgentMessage[] {
const last = messages[messages.length - 1];
if (!state || last?.role !== "user") return messages;
return [...messages.slice(0, -1), { ...last, content: [...last.content, { type: "text", text: state }] }];
}

/** A kept map-state block: the server's facts, never the person's words. */
export const isMapState = (c: AgentContent): boolean => c.type === "text" && c.text.startsWith("<map_state>");

/**
* The history after a turn failed: a user message the model never answered goes, unless
* it holds tool results — those record edits that were made, and the next request
Expand Down Expand Up @@ -241,7 +257,7 @@ export function groupTurns(messages: AgentMessage[]): ReplayTurn[] {
let cur: ReplayTurn | null = null;
for (const m of messages) {
if (m.role === "user" && !m.content.some((c) => c.type === "tool_result")) {
cur = { user: m.content.filter(isText).map((c) => c.text), steps: [], answer: null };
cur = { user: m.content.filter(isWords).map((c) => c.text), steps: [], answer: null };
turns.push(cur);
continue;
}
Expand Down Expand Up @@ -273,6 +289,8 @@ const PHASE_LABELS: Record<Phase, string> = { idle: msg("Ready"), waiting: msg("

/** Whether a message content item is a turn's own text (not tool traffic). */
const isText = (c: AgentContent): c is Extract<AgentContent, { type: "text" }> => c.type === "text";
/** Text a person or the model wrote, not a map-state block kept with a message. */
const isWords = (c: AgentContent): c is Extract<AgentContent, { type: "text" }> => isText(c) && !isMapState(c);

/** The teal outline of what the assistant is about to touch, drawn by an overlay while a tool runs. */
function intentOverlay(api: PluginApi): { handle: OverlayHandle; show(f: Footprint | null): void } {
Expand Down Expand Up @@ -528,7 +546,7 @@ export function openAssistant(ctx: Ctx, store: Conversations): AssistantHandle {
});
root.addEventListener("keydown", (e) => { if (e.key === "Escape" && running && e.target !== input) { e.preventDefault(); running.abort(); } });

const transcript = () => state.messages.map((m) => m.content.filter(isText).map((c) => (m.role === "user" ? t("You: {text}", { text: c.text }) : t("Assistant: {text}", { text: c.text }))).join("\n")).filter(Boolean).join("\n\n");
const transcript = () => state.messages.map((m) => m.content.filter(isWords).map((c) => (m.role === "user" ? t("You: {text}", { text: c.text }) : t("Assistant: {text}", { text: c.text }))).join("\n")).filter(Boolean).join("\n\n");

// Replay what the conversation holds, a block per turn.
const replay = () => {
Expand Down Expand Up @@ -720,8 +738,10 @@ export function openAssistant(ctx: Ctx, store: Conversations): AssistantHandle {
turnCost += charged;
setCost();
// Kept exactly as returned — thinking blocks included — and sent back unchanged next
// turn, since the model refuses to continue a tool-using turn without them.
// turn, since the model refuses to continue a tool-using turn without them; and the
// message it answered keeps the map state the server added to it, for the same reason.
const answer = r.output.content;
conv.messages = keepState(conv.messages, r.output.state);
conv.messages.push({ role: "assistant", content: answer });
const finalText = answer.filter(isText).map((c) => c.text).join("\n\n").trim();
const calls = answer.filter((c): c is Extract<AgentContent, { type: "tool_use" }> => c.type === "tool_use");
Expand Down
12 changes: 10 additions & 2 deletions dist/plugin.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@scm-js/plugin-scmjs-dev",
"version": "1.38.0",
"version": "1.39.0",
"private": true,
"description": "scmjs.dev — a plugin for the scmJS StarCraft map editor: your account, map storage with revisions, and the AI features (scenarios, maps, triggers, an assistant) on one free trial and one sign-in.",
"author": "Jeany",
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "scmjs.dev",
"id": "scmjs-dev",
"version": "1.38.0",
"version": "1.39.0",
"description": "Your scmjs.dev account in the editor. Keep maps on your account with revisions and notes, and share the open map so others can edit it with you at the same time. Where the server offers them, AI tools can build, lay out and review a map with you.",
"author": "Jeany",
"homepage": "https://github.com/scm-js/plugin-scmjs-dev",
Expand Down
8 changes: 8 additions & 0 deletions protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1543,5 +1543,13 @@ export interface AgentInput {
export interface AgentOutput {
content: AgentContent[];
stopReason: "end_turn" | "tool_use" | "max_tokens" | "refusal";
/**
* The map-state block the server added at the end of the last user message, exactly as
* the model saw it (servers from ai-engine 0.2.0). Keep it in the history, as a text block
* at the end of that message, so the next request sends the message unchanged: a model
* that checks replayed thinking (the 5.5 generation) drops every thinking block after a
* message that changed.
*/
state?: string;
}

31 changes: 31 additions & 0 deletions tests/reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,35 @@ describe("replaying a transcript", () => {
expect(calls.map((c) => [c.name, c.result, !!c.failed, !!c.image])).toEqual([["map_info", "{\"width\":64}", false, false], ["screenshot", "Tiles.", false, true], ["nope", "Error: no", true, false]]);
expect(turns[1]).toEqual({ user: ["Thanks"], steps: [], answer: "Sure." });
});

it("leaves the kept map-state blocks out of what the person asked", async () => {
const { groupTurns } = await import("../ai/assistant");
const turns = groupTurns([
{ role: "user", content: [{ type: "text", text: "Hi" }, { type: "text", text: "<map_state>\nUnits on the map:\n</map_state>" }] },
{ role: "assistant", content: [{ type: "text", text: "Hello." }] },
]);
expect(turns).toEqual([{ user: ["Hi"], steps: [], answer: "Hello." }]);
});
});

describe("keeping the map state", () => {
const state = "<map_state>\nView: tiles 0,0\n</map_state>";
it("puts the server's block at the end of the message it was added to, without touching the one before", async () => {
const { keepState } = await import("../ai/assistant");
const sent: AgentMessage[] = [
{ role: "user", content: [{ type: "text", text: "add marines" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "place_units", input: {} }] },
{ role: "user", content: [{ type: "tool_result", toolUseId: "t1", content: "placed" }] },
];
const kept = keepState(sent, state);
expect(kept[2]).toEqual({ role: "user", content: [{ type: "tool_result", toolUseId: "t1", content: "placed" }, { type: "text", text: state }] });
expect(kept.slice(0, 2)).toEqual(sent.slice(0, 2));
expect(sent[2].content).toHaveLength(1);
});

it("changes nothing for a server that sends no state", async () => {
const { keepState } = await import("../ai/assistant");
const sent: AgentMessage[] = [{ role: "user", content: [{ type: "text", text: "hi" }] }];
expect(keepState(sent, undefined)).toBe(sent);
});
});
Loading