mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: surface base + persona system prompts in observer feed (#1103)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
466bb993ca
commit
5004758fbf
@@ -435,12 +435,7 @@ async fn create_session_and_apply_model(
|
||||
// for the session/new request. Only sent when the agent declares protocol
|
||||
// version >= 2 (supports systemPrompt); legacy agents ignore it.
|
||||
let combined_system_prompt: Option<String> = if agent.protocol_version >= 2 {
|
||||
match (ctx.base_prompt, ctx.system_prompt.as_deref()) {
|
||||
(Some(bp), Some(sp)) => Some(format!("{}\n\n{sp}", bp.trim_end())),
|
||||
(Some(bp), None) => Some(bp.trim_end().to_string()),
|
||||
(None, Some(sp)) => Some(sp.to_string()),
|
||||
(None, None) => None,
|
||||
}
|
||||
framed_system_prompt(ctx.base_prompt, ctx.system_prompt.as_deref())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -660,6 +655,26 @@ pub(crate) fn prepend_base_for_legacy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame the `session/new` `systemPrompt` so each present prompt carries its own
|
||||
/// header, keeping the base/persona boundary recoverable downstream.
|
||||
///
|
||||
/// The header framing matches the legacy per-turn path (`queue::base_section`
|
||||
/// for `[Base]`, `[System]\n{...}` for the persona) so the desktop observer can
|
||||
/// split the combined value into labeled sub-sections. Each prompt is wrapped
|
||||
/// only when present, so a persona-only agent yields `[System]\n{persona}`
|
||||
/// rather than an unlabeled blob that would be mislabeled as `[Base]`.
|
||||
fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) -> Option<String> {
|
||||
match (base_prompt, system_prompt) {
|
||||
(Some(bp), Some(sp)) => Some(format!(
|
||||
"{}\n\n[System]\n{sp}",
|
||||
crate::queue::base_section(bp)
|
||||
)),
|
||||
(Some(bp), None) => Some(crate::queue::base_section(bp)),
|
||||
(None, Some(sp)) => Some(format!("[System]\n{sp}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Core async function spawned for each prompt.
|
||||
///
|
||||
/// Lifecycle:
|
||||
@@ -2336,6 +2351,36 @@ mod tests {
|
||||
assert_eq!(composed, "hello channel");
|
||||
}
|
||||
|
||||
// ── framed_system_prompt tests ───────────────────────────────────────────
|
||||
// Pin the session/new systemPrompt framing: each present prompt carries its
|
||||
// own header so the desktop observer can split into labeled sub-sections.
|
||||
|
||||
#[test]
|
||||
fn test_framed_system_prompt_both_present_carries_both_headers() {
|
||||
let framed = framed_system_prompt(Some("base text"), Some("persona text"))
|
||||
.expect("both present yields Some");
|
||||
assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_framed_system_prompt_base_only_labels_base() {
|
||||
let framed = framed_system_prompt(Some("base text"), None).expect("base yields Some");
|
||||
assert_eq!(framed, "[Base]\nbase text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_framed_system_prompt_persona_only_labels_system() {
|
||||
// A bare persona would be mislabeled "Base" downstream — it must carry
|
||||
// its own [System] header even when no base prompt exists.
|
||||
let framed = framed_system_prompt(None, Some("persona text")).expect("persona yields Some");
|
||||
assert_eq!(framed, "[System]\npersona text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_framed_system_prompt_neither_is_none() {
|
||||
assert!(framed_system_prompt(None, None).is_none());
|
||||
}
|
||||
|
||||
// ── parse_thread_response tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
extractToolIdentity,
|
||||
extractToolResult,
|
||||
parsePromptText,
|
||||
parseSystemPromptSections,
|
||||
} from "./agentSessionTranscriptHelpers";
|
||||
|
||||
export { describeRawEvent } from "./agentSessionTranscriptHelpers";
|
||||
@@ -335,6 +336,27 @@ export function processTranscriptEvent(
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.kind === "acp_write" && method === "session/new") {
|
||||
// The base + persona prompts ride session/new's systemPrompt, framed by
|
||||
// the harness as [Base]/[System]. Surface them as one "System prompt" item
|
||||
// keyed per channel-session — the frame carries no session id (it predates
|
||||
// session creation), and session/new fires once per channel-session, so a
|
||||
// re-created session correctly replaces the prior item.
|
||||
const params = asRecord(payload.params);
|
||||
const systemPrompt = asString(params.systemPrompt);
|
||||
if (systemPrompt) {
|
||||
const sections = parseSystemPromptSections(systemPrompt);
|
||||
if (sections.length > 0) {
|
||||
upsertMetadata(
|
||||
d,
|
||||
`system-prompt:${ch}`,
|
||||
"System prompt",
|
||||
sections,
|
||||
event.timestamp,
|
||||
channelId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.kind === "acp_read" && method === "session/update") {
|
||||
const params = asRecord(payload.params);
|
||||
const update = asRecord(params.update);
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
extractPromptText,
|
||||
parsePromptText,
|
||||
parseSystemPromptSections,
|
||||
} from "./agentSessionTranscriptHelpers.ts";
|
||||
|
||||
const HEX = "a".repeat(64);
|
||||
@@ -122,3 +123,51 @@ test("extractPromptText returns empty string when prompt is missing or not an ar
|
||||
assert.equal(extractPromptText({}), "");
|
||||
assert.equal(extractPromptText({ params: { prompt: "nope" } }), "");
|
||||
});
|
||||
|
||||
// --- parseSystemPromptSections: deterministic Base/System split ---
|
||||
|
||||
test("parseSystemPromptSections splits both prompts into Base and System", () => {
|
||||
const framed = "[Base]\nbase text\n\n[System]\npersona text";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{ title: "System", body: "persona text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections yields one Base section for a base-only frame", () => {
|
||||
const sections = parseSystemPromptSections("[Base]\nbase text");
|
||||
assert.deepEqual(sections, [{ title: "Base", body: "base text" }]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections yields one System section for a persona-only frame", () => {
|
||||
const sections = parseSystemPromptSections("[System]\npersona text");
|
||||
assert.deepEqual(sections, [{ title: "System", body: "persona text" }]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps embedded bracket lines literal in bodies", () => {
|
||||
// A persona that itself contains a [Context]-like line must NOT split into a
|
||||
// spurious sub-section — the body is read literally after the first boundary.
|
||||
const framed = "[Base]\nbase\n\n[System]\nrule one\n[Context]\nrule two";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base" },
|
||||
{ title: "System", body: "rule one\n[Context]\nrule two" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections degrades to a labeled Base when [System] header is elided", () => {
|
||||
// Oversize trim can drop the [System] header mid-string. Without a boundary
|
||||
// the whole value stays under a correctly-labeled Base — no missing label,
|
||||
// no inflated count, just a truncated body.
|
||||
const elided = "[Base]\nbase text …[elided 900000 bytes]… persona tail";
|
||||
const sections = parseSystemPromptSections(elided);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text …[elided 900000 bytes]… persona tail" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections returns no sections for empty input", () => {
|
||||
assert.deepEqual(parseSystemPromptSections(""), []);
|
||||
assert.deepEqual(parseSystemPromptSections(" "), []);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,46 @@ export function parsePromptText(text: string): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the framed `session/new` `systemPrompt` into its `Base`/`System`
|
||||
* sub-sections deterministically.
|
||||
*
|
||||
* The harness frames the value as `[Base]\n{base}\n\n[System]\n{persona}`, with
|
||||
* either prompt omitted when absent: base-only is `[Base]\n{base}`, persona-only
|
||||
* is `[System]\n{persona}`. We partition on the FIRST `\n[System]\n` boundary and
|
||||
* read each labeled body literally. Unlike the generic `parsePromptSections`,
|
||||
* embedded `[...]` lines inside a body never start a new section — so a persona
|
||||
* containing a bracketed line, or a mid-string-elided header on an oversize
|
||||
* prompt, can never drop a label or inflate the section count.
|
||||
*/
|
||||
export function parseSystemPromptSections(
|
||||
systemPrompt: string,
|
||||
): PromptSection[] {
|
||||
const sections: PromptSection[] = [];
|
||||
|
||||
// Persona-only frame: no [Base], starts directly with [System].
|
||||
if (systemPrompt.startsWith("[System]\n")) {
|
||||
const body = systemPrompt.slice("[System]\n".length).trim();
|
||||
if (body) sections.push({ title: "System", body });
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Otherwise the head (up to the first [System] boundary, or the whole string)
|
||||
// is the [Base] body.
|
||||
const marker = "\n[System]\n";
|
||||
const at = systemPrompt.indexOf(marker);
|
||||
const head = at === -1 ? systemPrompt : systemPrompt.slice(0, at);
|
||||
const baseBody = head.replace(/^\[Base]\n/, "").trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
|
||||
if (at !== -1) {
|
||||
const systemBody = systemPrompt.slice(at + marker.length).trim();
|
||||
sections.push({ title: "System", body: systemBody });
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function parsePromptSections(text: string): PromptSection[] {
|
||||
const sections: PromptSection[] = [];
|
||||
let current: PromptSection | null = null;
|
||||
|
||||
Reference in New Issue
Block a user