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
53 changes: 53 additions & 0 deletions src/agent/compaction-markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { AppMessage } from "./types.js";

export const COMPACTION_RESUME_PROMPT =
"Use the above summary to resume the plan from where we left off.";

function extractMessageText(message: AppMessage): string {
const content =
message.role === "assistant" || message.role === "user"
? message.content
: undefined;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((part) => {
if (part.type === "text") return part.text;
if (part.type === "thinking") return part.thinking;
return "";
})
.filter((part): part is string => Boolean(part))
.join(" ");
}

export function isDecoratedCompactionSummaryText(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
return (
normalized.includes(
"Another language model started to solve this problem",
) ||
normalized.includes("(Compacted") ||
normalized.includes("_Local summary of prior discussion")
);
}

export function isDecoratedCompactionSummaryMessage(
message: AppMessage,
): boolean {
if (message.role !== "assistant") return false;
return isDecoratedCompactionSummaryText(extractMessageText(message));
}

export function isCompactionResumePromptText(text: string): boolean {
return text.trim() === COMPACTION_RESUME_PROMPT;
}

export function isCompactionResumePromptMessage(message: AppMessage): boolean {
if (message.role !== "user") return false;
return isCompactionResumePromptText(extractMessageText(message));
}
61 changes: 14 additions & 47 deletions src/agent/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ import {
PLAN_FILE_COMPACTION_CUSTOM_TYPE,
PLAN_MODE_COMPACTION_CUSTOM_TYPE,
} from "./compaction-restoration.js";
export {
COMPACTION_RESUME_PROMPT,
isCompactionResumePromptMessage,
isCompactionResumePromptText,
isDecoratedCompactionSummaryMessage,
isDecoratedCompactionSummaryText,
} from "./compaction-markers.js";
import {
COMPACTION_RESUME_PROMPT,
isCompactionResumePromptMessage,
isCompactionResumePromptText,
isDecoratedCompactionSummaryMessage,
isDecoratedCompactionSummaryText,
} from "./compaction-markers.js";
import {
isContextOverflow as isCompactionOverflowMessage,
isOverflowErrorMessage,
Expand Down Expand Up @@ -111,13 +125,6 @@ const logger = createLogger("agent:compaction");
// Types
// ============================================================================

/**
* Internal user prompt appended after compaction so the model resumes from the
* summarized context on the next turn.
*/
export const COMPACTION_RESUME_PROMPT =
"Use the above summary to resume the plan from where we left off.";

const MAX_COMPACTION_OVERFLOW_RETRIES = 3;
const PREVIOUS_SUMMARY_PREFIX = "Previous session summary:\n";
const COMPACTION_OVERFLOW_RETRY_MARKER =
Expand Down Expand Up @@ -1492,46 +1499,6 @@ export function decorateSummaryText(
return `${handoffPrefix}${summaryText}\n\n(Compacted ${compactedCount} messages on ${new Date().toLocaleString()})`;
}

/**
* Check whether text matches the decorated compaction summary format.
*/
export function isDecoratedCompactionSummaryText(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
return (
normalized.includes(
"Another language model started to solve this problem",
) ||
normalized.includes("(Compacted") ||
normalized.includes("_Local summary of prior discussion")
);
}

/**
* Check whether a message is a decorated assistant compaction summary.
*/
export function isDecoratedCompactionSummaryMessage(
message: AppMessage,
): boolean {
if (message.role !== "assistant") return false;
return isDecoratedCompactionSummaryText(extractMessageText(message));
}

/**
* Check whether text matches the internal post-compaction resume prompt.
*/
export function isCompactionResumePromptText(text: string): boolean {
return text.trim() === COMPACTION_RESUME_PROMPT;
}

/**
* Check whether a message is the internal post-compaction resume prompt.
*/
export function isCompactionResumePromptMessage(message: AppMessage): boolean {
if (message.role !== "user") return false;
return isCompactionResumePromptText(extractMessageText(message));
}

/**
* Merge a history summary with a turn prefix summary for split turn compaction.
*
Expand Down
189 changes: 189 additions & 0 deletions src/agent/session-start-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { createSessionHookService } from "../hooks/session-integration.js";
import { createLogger } from "../utils/logger.js";
import type { Agent } from "./agent.js";
import { createHookMessage } from "./custom-messages.js";
import { SESSION_START_INITIAL_USER_METADATA_KIND } from "./session-start-metadata.js";
import type { AppMessage, HookMessage, UserMessage } from "./types.js";

type SessionStartSessionManager = {
getSessionId?: () => string | undefined;
saveMessage?: (message: AppMessage) => void;
};
type SessionStartHookDelivery = "queue" | "persistHistory";

interface SessionStartHookOutputs {
systemMessage?: string;
additionalContext?: string;
initialUserMessage?: string;
}

const logger = createLogger("session-start-hooks");

function buildSessionStartHookContextMessage(text: string): HookMessage {
return createHookMessage(
"SessionStart",
text,
true,
undefined,
new Date().toISOString(),
);
}

function buildSessionStartInitialUserMessage(
text: string,
source?: string,
): UserMessage {
return {
role: "user",
content: text,
metadata: {
kind: SESSION_START_INITIAL_USER_METADATA_KIND,
source,
},
timestamp: Date.now(),
};
}

function buildSessionStartHookSystemGuidance(text: string): string {
return `SessionStart hook system guidance:\n${text}`;
}

function buildPersistedSessionStartHookSystemMessage(
text: string,
): HookMessage {
return createHookMessage(
"SessionStart",
buildSessionStartHookSystemGuidance(text),
true,
undefined,
new Date().toISOString(),
);
}

async function runSessionStartHooksInternal(params: {
sessionManager: SessionStartSessionManager;
cwd: string;
source: string;
signal?: AbortSignal;
}): Promise<SessionStartHookOutputs | null> {
const service = createSessionHookService({
cwd: params.cwd,
sessionId: params.sessionManager.getSessionId?.(),
});
if (!service.hasHooks("SessionStart")) {
return null;
}

const result = await service.runSessionStartHooks(
params.source,
params.signal,
);
if (result.blocked || result.preventContinuation) {
logger.warn(
"SessionStart hook attempted to stop session startup; ignoring control flow request",
{
source: params.source,
blocked: result.blocked,
preventContinuation: result.preventContinuation,
reason: result.blockReason ?? result.stopReason,
},
);
}

return {
systemMessage: result.systemMessage?.trim(),
additionalContext: result.additionalContext?.trim(),
initialUserMessage: result.initialUserMessage?.trim(),
};
}

function buildPersistedSessionStartHookMessages(
outputs: SessionStartHookOutputs | null,
source: string,
): AppMessage[] {
if (!outputs) {
return [];
}

const persistedMessages: AppMessage[] = [];
if (outputs.systemMessage) {
persistedMessages.push(
buildPersistedSessionStartHookSystemMessage(outputs.systemMessage),
);
}
if (outputs.additionalContext) {
persistedMessages.push(
buildSessionStartHookContextMessage(outputs.additionalContext),
);
}
if (outputs.initialUserMessage) {
persistedMessages.push(
buildSessionStartInitialUserMessage(outputs.initialUserMessage, source),
);
}
return persistedMessages;
}

export async function collectPersistedSessionStartHookMessages(params: {
sessionManager: SessionStartSessionManager;
cwd: string;
source: string;
signal?: AbortSignal;
}): Promise<AppMessage[]> {
return buildPersistedSessionStartHookMessages(
await runSessionStartHooksInternal(params),
params.source,
);
}

export async function applySessionStartHooks(params: {
agent: Agent;
sessionManager: SessionStartSessionManager;
cwd: string;
source: string;
signal?: AbortSignal;
delivery?: SessionStartHookDelivery;
}): Promise<void> {
if (params.delivery === "persistHistory") {
const persistedMessages = await collectPersistedSessionStartHookMessages({
sessionManager: params.sessionManager,
cwd: params.cwd,
source: params.source,
signal: params.signal,
});
for (const message of persistedMessages) {
params.agent.appendMessage(message);
params.sessionManager.saveMessage?.(message);
}
return;
}

const outputs = await runSessionStartHooksInternal({
sessionManager: params.sessionManager,
cwd: params.cwd,
source: params.source,
signal: params.signal,
});
if (!outputs) {
return;
}

if (outputs.systemMessage) {
params.agent.queueNextRunSystemPromptAddition(
buildSessionStartHookSystemGuidance(outputs.systemMessage),
);
}
if (outputs.additionalContext) {
params.agent.queueNextRunHistoryMessage(
buildSessionStartHookContextMessage(outputs.additionalContext),
);
}
if (outputs.initialUserMessage) {
params.agent.queueNextRunHistoryMessage(
buildSessionStartInitialUserMessage(
outputs.initialUserMessage,
params.source,
),
);
}
}
13 changes: 8 additions & 5 deletions src/app-server/plugin-bundle-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,12 @@ function paramsRecord(params: unknown): UnknownRecord {
}

function projectRootFromParams(
_options: MaestroAppServerPluginBundleApiOptions,
params: UnknownRecord,
options: MaestroAppServerPluginBundleApiOptions,
): string {
return resolve(_options.projectRoot ?? process.cwd());
return resolve(
stringValue(params.projectRoot) ?? options.projectRoot ?? process.cwd(),
);
}

function scopeFromParams(params: UnknownRecord): WritablePackageScope {
Expand Down Expand Up @@ -221,7 +224,7 @@ export function createMaestroAppServerPluginBundleApi(
return {
async listBundles(params = {}) {
const normalizedParams = paramsRecord(params);
const projectRoot = projectRootFromParams(options);
const projectRoot = projectRootFromParams(normalizedParams, options);
const resources = loadConfiguredPackageResources(projectRoot);
return {
bundles: loadConfiguredPackageSpecs(projectRoot).map((entry) => ({
Expand All @@ -241,7 +244,7 @@ export function createMaestroAppServerPluginBundleApi(

async installBundle(params = {}) {
const normalizedParams = paramsRecord(params);
const projectRoot = projectRootFromParams(options);
const projectRoot = projectRootFromParams(normalizedParams, options);
const spec = packageSpecFromParams(normalizedParams);
const source = sourceString(spec);
const scope = scopeFromParams(normalizedParams);
Expand Down Expand Up @@ -282,7 +285,7 @@ export function createMaestroAppServerPluginBundleApi(

async removeBundle(params = {}) {
const normalizedParams = paramsRecord(params);
const projectRoot = projectRootFromParams(options);
const projectRoot = projectRootFromParams(normalizedParams, options);
const spec = packageSpecFromParams(normalizedParams);
const scope =
normalizedParams.scope === undefined
Expand Down
6 changes: 2 additions & 4 deletions src/app-server/session-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,11 +895,8 @@ export function createMaestroAppServerSessionApi(
options.sandboxCheck === false
? undefined
: (options.sandboxCheck ?? createMaestroAppServerSandboxCheck());
const canMutateSessionPersistence = store.canCreateSession?.() ?? true;
const pluginBundles =
options.pluginBundles === false ||
!canMutateSessionPersistence ||
!hostControl
options.pluginBundles === false
? undefined
: (options.pluginBundles ?? createMaestroAppServerPluginBundleApi());
const daemonLifecycle =
Expand Down Expand Up @@ -927,6 +924,7 @@ export function createMaestroAppServerSessionApi(
const canUseThreadGoals = Boolean(
store.setSessionAppServerGoal && store.loadEntries,
);
const canMutateSessionPersistence = store.canCreateSession?.() ?? true;
const externalAgentImport =
options.externalAgentImport === false || !canMutateSessionPersistence
? undefined
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/exec-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Prefix added to exec session summaries for identification. */
export const EXEC_SESSION_SUMMARY_PREFIX = "[exec]";
Loading
Loading