RunRequest: add system_mode (append|replace)

Why this matters:
- Default 'append' (--append-system-prompt): caller's system text
  is ADDED to Claude's default base prompt ('I am Claude, an AI
  assistant...'). Right for tool-using assistants and coding agents
  where the helpful-honest defaults are useful substrate. Every
  existing caller (cauldron, petalparse, etc) ran in this mode
  implicitly.

- New 'replace' (--system-prompt): caller's system text REPLACES
  the default base prompt entirely. Right for personas — fiction
  authors, chat bots, in-world characters — where Claude's defaults
  would bleed through as friction. The model BECOMES the persona
  rather than 'Claude playing the persona.'

Trigger for adding this: skald v0.3 — authors as personas with
souls. Orson Black writing a Chernobyl piece can't have Claude's
default helpfulness softening the prose; the soul needs to be the
prompt, not append to it.

Wire shape:
- server.py: Literal['append','replace']|None in RunRequest, default 'append'
- runner.py: branch on system_mode to pick the right CLI flag
- clients/rust: SystemMode enum, Option<SystemMode> on RunRequest

Backward compatible: clients that don't set system_mode get append
semantics (the existing behavior).
This commit is contained in:
Kayos 2026-05-13 11:27:40 -07:00
parent 015348c526
commit d4c3a9d2de
4 changed files with 66 additions and 4 deletions

View file

@ -33,6 +33,7 @@ class Runner:
prompt: str,
model: str | None = None,
system: str | None = None,
system_mode: str | None = "append",
files: list[str] | None = None,
timeout_secs: int | None = None,
) -> RunResult:
@ -50,7 +51,15 @@ class Runner:
"--model", model or self.default_model,
]
if system:
cmd += ["--append-system-prompt", system]
# "append" = additive on top of claude's defaults; right
# for tool-using assistants. "replace" = REPLACES the
# default base prompt entirely; right for personas
# (fiction authors, chat bots, in-world characters) where
# claude's defaults would bleed through as friction.
if system_mode == "replace":
cmd += ["--system-prompt", system]
else:
cmd += ["--append-system-prompt", system]
if files:
for f in files:
cmd += ["--files", f]

View file

@ -19,7 +19,7 @@ import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from typing import Annotated, Literal
from fastapi import FastAPI, Header, HTTPException, Request, UploadFile, File, Form
from fastapi.responses import JSONResponse
@ -103,6 +103,25 @@ class RunRequest(BaseModel):
prompt: str = Field(min_length=1)
model: str | None = None
system: str | None = None
# How `system` gets passed to the underlying claude CLI:
#
# "append" → `--append-system-prompt`. Claude's default base
# prompt stays + the caller's `system` is added.
# Right for assistants / tool-use / coding agents
# where Claude's helpful-honest defaults are
# desirable substrate.
#
# "replace" → `--system-prompt`. Claude's default base prompt
# is REPLACED entirely by the caller's `system`.
# Right for personas (fiction authors, chat bots,
# in-world characters) where Claude's defaults
# would bleed through as friction. The model
# BECOMES the persona instead of "Claude playing
# the persona."
#
# Default is "append" for back-compat — every caller before this
# field landed used append semantics implicitly.
system_mode: Literal["append", "replace"] | None = "append"
files: list[str] | None = None
timeout_secs: int | None = Field(default=None, ge=5, le=600)
@ -183,6 +202,7 @@ def run(
prompt=body.prompt,
model=body.model,
system=body.system,
system_mode=body.system_mode,
files=file_paths or None,
timeout_secs=body.timeout_secs,
)

View file

@ -54,5 +54,5 @@ pub use error::Error;
pub use session::{Session, SessionList, SessionOptions, SessionState, TurnEvent, TurnResult};
pub use types::{
AppToken, AppTokenInfo, FileToken, Healthz, RunFailure, RunRequest, RunResult,
TokenCreateRequest, TokenList,
SystemMode, TokenCreateRequest, TokenList,
};

View file

@ -9,6 +9,23 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::error::Error;
/// How `system` is passed to `claude -p`. See [`RunRequest::system_mode`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemMode {
/// `--append-system-prompt` — additive on top of Claude's defaults.
Append,
/// `--system-prompt` — REPLACES Claude's default base prompt
/// entirely. Right for personas.
Replace,
}
impl Default for SystemMode {
fn default() -> Self {
Self::Append
}
}
/// `GET /healthz` response body.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Healthz {
@ -43,10 +60,26 @@ pub struct RunRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// System prompt appended via `claude -p --append-system-prompt`.
/// System prompt. How it's passed to `claude -p` depends on
/// [`Self::system_mode`].
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
/// How `system` is passed to `claude -p`:
///
/// - [`SystemMode::Append`] (default) → `--append-system-prompt`.
/// Claude's default base prompt stays + `system` is added.
/// Right for tool-using assistants where Claude's helpful-
/// honest defaults are useful substrate.
/// - [`SystemMode::Replace`] → `--system-prompt`. Replaces the
/// base prompt entirely. Right for personas (fiction authors,
/// chat bots, in-world characters) where Claude's defaults
/// would bleed through as friction.
///
/// `None` = server default (currently `append`).
#[serde(skip_serializing_if = "Option::is_none")]
pub system_mode: Option<SystemMode>,
/// File tokens previously returned from [`Client::upload_file`].
///
/// [`Client::upload_file`]: crate::Client::upload_file