fix(plugin): move session-start to chat.message to fix Jinja system-message error
experimental.chat.system.transform fires for task-spawned subagent sessions after the task prompt (user message) is already in the conversation. Pushing to output.system at that point places a system message at a non-zero position, which Qwen3.6's strict Jinja chat template rejects with 'System message must be at the beginning.' Move session-start.sh injection to chat.message (first turn guard via initializedSessions). Injected as a synthetic text part via unshift(), which has no position constraints.
This commit is contained in:
parent
5c1225744a
commit
f0d21e9895
@ -1,12 +1,12 @@
|
|||||||
import type { Plugin, TextPart } from '@opencode-ai/plugin';
|
import type { Plugin, TextPart } from "@opencode-ai/plugin";
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname } from "node:path";
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent support plugin for Remnant.
|
* Agent support plugin for Remnant.
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* 1. experimental.chat.system.transform — session-start.sh (once per session)
|
* 1. chat.message (first turn) — session-start.sh (once per session)
|
||||||
* 2. chat.message — user-prompt-submit.sh (each turn)
|
* 2. chat.message — user-prompt-submit.sh (each turn)
|
||||||
* 3. tool.execute.before — pre-tool-use.sh (project policy)
|
* 3. tool.execute.before — pre-tool-use.sh (project policy)
|
||||||
* 4. tool.execute.after — post-tool-use.sh + context pressure warning
|
* 4. tool.execute.after — post-tool-use.sh + context pressure warning
|
||||||
@ -26,7 +26,10 @@ const PRESSURE_THRESHOLD = 0.7; // 70%
|
|||||||
const LOCAL_WORKER_MAX_TOKENS = 1500;
|
const LOCAL_WORKER_MAX_TOKENS = 1500;
|
||||||
const LOCAL_ORCHESTRATOR_MAX_TOKENS = 2500;
|
const LOCAL_ORCHESTRATOR_MAX_TOKENS = 2500;
|
||||||
|
|
||||||
function truncate(text: string, maxTokens: number): { text: string; truncated: boolean } {
|
function truncate(
|
||||||
|
text: string,
|
||||||
|
maxTokens: number,
|
||||||
|
): { text: string; truncated: boolean } {
|
||||||
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
||||||
if (text.length <= maxChars) return { text, truncated: false };
|
if (text.length <= maxChars) return { text, truncated: false };
|
||||||
return {
|
return {
|
||||||
@ -42,14 +45,16 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
// This makes the plugin work both as a project-local plugin and as a global
|
// This makes the plugin work both as a project-local plugin and as a global
|
||||||
// plugin installed via install.sh — in either case, hooks live in ../../hooks/
|
// plugin installed via install.sh — in either case, hooks live in ../../hooks/
|
||||||
// relative to this file in the .agents/frameworks/opencode/ directory.
|
// relative to this file in the .agents/frameworks/opencode/ directory.
|
||||||
const hooksDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../hooks');
|
const hooksDir = resolve(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../hooks",
|
||||||
|
);
|
||||||
|
|
||||||
// Running cumulative context size estimate (characters)
|
// Running cumulative context size estimate (characters)
|
||||||
let contextCharsUsed = 0;
|
let contextCharsUsed = 0;
|
||||||
|
|
||||||
// Track sessions that have had session-start injected (system.transform fires every turn)
|
// Track sessions that have had session-start injected (fires once per session)
|
||||||
const initializedSessions = new Set<string>();
|
const initializedSessions = new Set<string>();
|
||||||
|
|
||||||
/** Parse the additionalContext string from a hook's JSON output. */
|
/** Parse the additionalContext string from a hook's JSON output. */
|
||||||
function parseAdditionalContext(hookOutput: string): string | undefined {
|
function parseAdditionalContext(hookOutput: string): string | undefined {
|
||||||
try {
|
try {
|
||||||
@ -62,7 +67,10 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runHook(scriptName: string, stdinJson?: string): Promise<string> {
|
async function runHook(
|
||||||
|
scriptName: string,
|
||||||
|
stdinJson?: string,
|
||||||
|
): Promise<string> {
|
||||||
const script = `${hooksDir}/${scriptName}`;
|
const script = `${hooksDir}/${scriptName}`;
|
||||||
try {
|
try {
|
||||||
const proc = stdinJson
|
const proc = stdinJson
|
||||||
@ -72,61 +80,85 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// DEBUG: log hook failures so silent catches don't hide enforcement bugs
|
// DEBUG: log hook failures so silent catches don't hide enforcement bugs
|
||||||
try {
|
try {
|
||||||
const fs = await import('node:fs');
|
const fs = await import("node:fs");
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
'/tmp/plugin-hook-errors.log',
|
"/tmp/plugin-hook-errors.log",
|
||||||
JSON.stringify({ ts: new Date().toISOString(), script, error: String(_error) }) + '\n'
|
JSON.stringify({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
script,
|
||||||
|
error: String(_error),
|
||||||
|
}) + "\n",
|
||||||
);
|
);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
// Hooks are advisory — never block on hook failure
|
// Hooks are advisory — never block on hook failure
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// ── 1. Session start: inject project state into system prompt ────────────
|
// ── 1 & 2. Session start + user prompt ──────────────────────────────────
|
||||||
// Uses system.transform rather than the defunct session.created event.
|
// Session-start was previously injected via experimental.chat.system.transform
|
||||||
// Guarded by initializedSessions so it runs exactly once per session.
|
// (pushing to output.system). That caused a Jinja "System message must be at
|
||||||
'experimental.chat.system.transform': async (input, output) => {
|
// the beginning" error on Qwen-family local models when the orchestrator spawns
|
||||||
const sessionID = input.sessionID ?? 'unknown';
|
// a subagent via `task`: system.transform fires after the task prompt (a user
|
||||||
if (initializedSessions.has(sessionID)) return;
|
// message) is already in the conversation, so the system push lands at a
|
||||||
initializedSessions.add(sessionID);
|
// non-zero position. Injecting as a synthetic text part on the first
|
||||||
const hookOutput = await runHook('session-start.sh');
|
// chat.message turn avoids the position constraint entirely.
|
||||||
const context = parseAdditionalContext(hookOutput);
|
"chat.message": async (input, output) => {
|
||||||
if (context) output.system.push(context);
|
const sessionID = input.sessionID ?? "unknown";
|
||||||
},
|
|
||||||
|
// Session-start injection — runs exactly once per session, prepended so it
|
||||||
|
// reads before the user-prompt-submit nudges on the first turn.
|
||||||
|
if (!initializedSessions.has(sessionID)) {
|
||||||
|
initializedSessions.add(sessionID);
|
||||||
|
const startOutput = await runHook("session-start.sh");
|
||||||
|
const startContext = parseAdditionalContext(startOutput);
|
||||||
|
if (startContext) {
|
||||||
|
output.parts.unshift({
|
||||||
|
id: `prt_${crypto.randomUUID()}`,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: input.messageID ?? crypto.randomUUID(),
|
||||||
|
type: "text",
|
||||||
|
text: startContext,
|
||||||
|
synthetic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 2. User prompt: task capture + CURRENT QUESTION + nudges ──────────────
|
|
||||||
// Equivalent to Copilot's UserPromptSubmit hook.
|
|
||||||
'chat.message': async (input, output) => {
|
|
||||||
const promptText = output.parts
|
const promptText = output.parts
|
||||||
.filter((p): p is TextPart => p.type === 'text')
|
.filter((p): p is TextPart => p.type === "text")
|
||||||
.map(p => p.text)
|
.map((p) => p.text)
|
||||||
.join('\n');
|
.join("\n");
|
||||||
const hookOutput = await runHook('user-prompt-submit.sh', JSON.stringify({ prompt: promptText }));
|
const hookOutput = await runHook(
|
||||||
|
"user-prompt-submit.sh",
|
||||||
|
JSON.stringify({ prompt: promptText }),
|
||||||
|
);
|
||||||
const context = parseAdditionalContext(hookOutput);
|
const context = parseAdditionalContext(hookOutput);
|
||||||
if (context) {
|
if (context) {
|
||||||
output.parts.push({
|
output.parts.push({
|
||||||
id: `prt_${crypto.randomUUID()}`,
|
id: `prt_${crypto.randomUUID()}`,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
messageID: input.messageID ?? crypto.randomUUID(),
|
messageID: input.messageID ?? crypto.randomUUID(),
|
||||||
type: 'text',
|
type: "text",
|
||||||
text: context,
|
text: context,
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 3. Pre-tool-use ─────────────────────────────────────────────────────
|
// ── 3. Pre-tool-use ─────────────────────────────────────────────────────
|
||||||
'tool.execute.before': async (input, output) => {
|
"tool.execute.before": async (input, output) => {
|
||||||
const toolName = input.tool as string;
|
const toolName = input.tool as string;
|
||||||
|
|
||||||
// ── read guards ───────────────────────────────────────────────────
|
// ── read guards ───────────────────────────────────────────────────
|
||||||
if (toolName === 'read') {
|
if (toolName === "read") {
|
||||||
const args = (output.args ?? {}) as { filePath?: string; offset?: number; limit?: number };
|
const args = (output.args ?? {}) as {
|
||||||
const filePath = args.filePath ?? '';
|
filePath?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
const filePath = args.filePath ?? "";
|
||||||
|
|
||||||
// package.json read guard:
|
// package.json read guard:
|
||||||
// Reading workspace package.json files auto-loads nested AGENTS.md files
|
// Reading workspace package.json files auto-loads nested AGENTS.md files
|
||||||
@ -134,7 +166,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
// Block package.json reads under apps/ and packages/ only.
|
// Block package.json reads under apps/ and packages/ only.
|
||||||
if (/(^|\/)(apps|packages)\/[^/]+\/package\.json$/.test(filePath)) {
|
if (/(^|\/)(apps|packages)\/[^/]+\/package\.json$/.test(filePath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'BLOCKED: Reading workspace package.json files auto-loads nested AGENTS.md files and exhausts the 32K context. Use `grep_search` to find the specific field you need (e.g. a dependency version or script name) instead of reading the whole file.'
|
"BLOCKED: Reading workspace package.json files auto-loads nested AGENTS.md files and exhausts the 32K context. Use `grep_search` to find the specific field you need (e.g. a dependency version or script name) instead of reading the whole file.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +178,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
// Directory reads (e.g. `Read .`) never carry a limit — skip the guard.
|
// Directory reads (e.g. `Read .`) never carry a limit — skip the guard.
|
||||||
let isDirectory = false;
|
let isDirectory = false;
|
||||||
try {
|
try {
|
||||||
const { statSync } = await import('node:fs');
|
const { statSync } = await import("node:fs");
|
||||||
isDirectory = statSync(filePath).isDirectory();
|
isDirectory = statSync(filePath).isDirectory();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// path doesn't exist or inaccessible — treat as file
|
// path doesn't exist or inaccessible — treat as file
|
||||||
@ -158,7 +190,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
isDocsFile
|
isDocsFile
|
||||||
? `BLOCKED: Unbounded read (no limit) is prohibited. Specify offset and limit to read in ≤500-line chunks for docs/ files.`
|
? `BLOCKED: Unbounded read (no limit) is prohibited. Specify offset and limit to read in ≤500-line chunks for docs/ files.`
|
||||||
: `BLOCKED: Unbounded read (no limit) is prohibited. Use grep_search first to find the relevant section, then read with offset and limit in ≤50-line chunks.`
|
: `BLOCKED: Unbounded read (no limit) is prohibited. Use grep_search first to find the relevant section, then read with offset and limit in ≤50-line chunks.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const lineLimit = isDocsFile ? 500 : 50;
|
const lineLimit = isDocsFile ? 500 : 50;
|
||||||
@ -166,7 +198,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
isDocsFile
|
isDocsFile
|
||||||
? `BLOCKED: Read more than 500 lines at once is prohibited for docs/ files. Use offset and limit to paginate in ≤500-line chunks.`
|
? `BLOCKED: Read more than 500 lines at once is prohibited for docs/ files. Use offset and limit to paginate in ≤500-line chunks.`
|
||||||
: `BLOCKED: Read more than 50 lines at once is prohibited. Use offset and limit to paginate in ≤50-line chunks. For docs/ files the limit is 500 lines. Use grep_search first to find the right offset.`
|
: `BLOCKED: Read more than 50 lines at once is prohibited. Use offset and limit to paginate in ≤50-line chunks. For docs/ files the limit is 500 lines. Use grep_search first to find the right offset.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,12 +209,12 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
// or long inventories inline in a task prompt causes "Unterminated string"
|
// or long inventories inline in a task prompt causes "Unterminated string"
|
||||||
// parse errors. Cap task prompts at 1200 chars — workers should be told
|
// parse errors. Cap task prompts at 1200 chars — workers should be told
|
||||||
// WHICH files to read, not given the contents inline.
|
// WHICH files to read, not given the contents inline.
|
||||||
if (toolName === 'task') {
|
if (toolName === "task") {
|
||||||
const args = (output.args ?? {}) as { prompt?: string };
|
const args = (output.args ?? {}) as { prompt?: string };
|
||||||
const prompt = args.prompt ?? '';
|
const prompt = args.prompt ?? "";
|
||||||
if (prompt.length > 1200) {
|
if (prompt.length > 1200) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BLOCKED (task prompt too long: ${prompt.length} chars, max 1200): Task prompts must not embed file contents, dependency lists, or long context inline — this causes JSON parse failures. Instead, tell the worker WHICH files to read and WHAT to do. Example: "Read the root package.json and all workspace package.json files, then update the Technology Stack section in README.md to match."`
|
`BLOCKED (task prompt too long: ${prompt.length} chars, max 1200): Task prompts must not embed file contents, dependency lists, or long context inline — this causes JSON parse failures. Instead, tell the worker WHICH files to read and WHAT to do. Example: "Read the root package.json and all workspace package.json files, then update the Technology Stack section in README.md to match."`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,31 +226,37 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
tool_name: toolName,
|
tool_name: toolName,
|
||||||
tool_input: output.args ?? {},
|
tool_input: output.args ?? {},
|
||||||
});
|
});
|
||||||
const hookResult = await runHook('pre-tool-use.sh', hookInput);
|
const hookResult = await runHook("pre-tool-use.sh", hookInput);
|
||||||
|
|
||||||
// If the hook emitted a deny decision, surface it as an error
|
// If the hook emitted a deny decision, surface it as an error
|
||||||
if (hookResult.includes('"permissionDecision": "deny"')) {
|
if (hookResult.includes('"permissionDecision": "deny"')) {
|
||||||
const match = hookResult.match(/"permissionDecisionReason":\s*"([^"]+)"/);
|
const match = hookResult.match(
|
||||||
const reason = match?.[1] ?? 'Blocked by project policy (pre-tool-use hook).';
|
/"permissionDecisionReason":\s*"([^"]+)"/,
|
||||||
|
);
|
||||||
|
const reason =
|
||||||
|
match?.[1] ?? "Blocked by project policy (pre-tool-use hook).";
|
||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 4. Post-tool-use ────────────────────────────────────────────────────
|
// ── 4. Post-tool-use ────────────────────────────────────────────────────
|
||||||
'tool.execute.after': async (input, output) => {
|
"tool.execute.after": async (input, output) => {
|
||||||
const response = output.response as string | undefined;
|
const response = output.response as string | undefined;
|
||||||
|
|
||||||
if (typeof response === 'string') {
|
if (typeof response === "string") {
|
||||||
// a) Response truncation — local agents (build/orchestrator) and any ollama/ model;
|
// a) Response truncation — local agents (build/orchestrator) and any ollama/ model;
|
||||||
// orchestrator gets a higher limit since it only reads, not edits.
|
// orchestrator gets a higher limit since it only reads, not edits.
|
||||||
const agentName = typeof input.agent === 'string' ? input.agent : '';
|
const agentName = typeof input.agent === "string" ? input.agent : "";
|
||||||
const isLocalAgent =
|
const isLocalAgent =
|
||||||
agentName === 'build' ||
|
agentName === "build" ||
|
||||||
agentName === 'orchestrator' ||
|
agentName === "orchestrator" ||
|
||||||
(typeof input.model === 'string' && input.model.startsWith('ollama/'));
|
(typeof input.model === "string" &&
|
||||||
|
input.model.startsWith("ollama/"));
|
||||||
if (isLocalAgent) {
|
if (isLocalAgent) {
|
||||||
const isOrchestrator = agentName === 'orchestrator';
|
const isOrchestrator = agentName === "orchestrator";
|
||||||
const maxTokens = isOrchestrator ? LOCAL_ORCHESTRATOR_MAX_TOKENS : LOCAL_WORKER_MAX_TOKENS;
|
const maxTokens = isOrchestrator
|
||||||
|
? LOCAL_ORCHESTRATOR_MAX_TOKENS
|
||||||
|
: LOCAL_WORKER_MAX_TOKENS;
|
||||||
const { text: truncated } = truncate(response, maxTokens);
|
const { text: truncated } = truncate(response, maxTokens);
|
||||||
output.response = truncated;
|
output.response = truncated;
|
||||||
}
|
}
|
||||||
@ -242,13 +280,13 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => {
|
|||||||
tool_input: input.args ?? {},
|
tool_input: input.args ?? {},
|
||||||
tool_response: (output.response as string).slice(0, 500), // truncated for hook
|
tool_response: (output.response as string).slice(0, 500), // truncated for hook
|
||||||
});
|
});
|
||||||
await runHook('post-tool-use.sh', hookInput);
|
await runHook("post-tool-use.sh", hookInput);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── 5. Pre-compact: export state before context summarization ─────────────
|
// ── 5. Pre-compact: export state before context summarization ─────────────
|
||||||
'experimental.session.compacting': async (input, output) => {
|
"experimental.session.compacting": async (input, output) => {
|
||||||
await runHook('pre-compact.sh');
|
await runHook("pre-compact.sh");
|
||||||
|
|
||||||
output.prompt = `
|
output.prompt = `
|
||||||
You are a context summarizer for coding sessions. Summarize only the conversation history given — do not answer it.
|
You are a context summarizer for coding sessions. Summarize only the conversation history given — do not answer it.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user