Skip to content

Commit 6c08d7d

Browse files
committed
Fix agent tool prompt consistency
1 parent 6bba6e1 commit 6c08d7d

3 files changed

Lines changed: 156 additions & 23 deletions

File tree

packages/agent-runtime/src/context/protocol/mastra/mastra-provider-prompt-guard-processor.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export class MastraProviderPromptGuardProcessor implements Processor<"provider-p
2727
const promptTokens = this.tokenCounter.countProviderPrompt(args.prompt, this.options.modelName);
2828
const inputBudget = Math.max(profile.contextWindow - profile.outputReserve - profile.safetyMargin, 0);
2929
const remainingTokens = inputBudget - promptTokens;
30+
const unavailableToolReferences = findUnavailableToolReferences(args.prompt);
3031
this.options.eventSink.emitContextEvent("context.prompt-verified", {
3132
step_number: args.stepNumber,
3233
model_profile_id: profile.id,
@@ -38,9 +39,18 @@ export class MastraProviderPromptGuardProcessor implements Processor<"provider-p
3839
// the verified-prompt snapshot (no completion tokens yet at this stage).
3940
...(this.options.modelName ? { model: this.options.modelName } : {}),
4041
total_tokens: promptTokens,
41-
budget_tokens: inputBudget
42+
budget_tokens: inputBudget,
43+
...(unavailableToolReferences.length > 0
44+
? { unavailable_tool_references: unavailableToolReferences }
45+
: {})
4246
});
4347

48+
if (unavailableToolReferences.length > 0) {
49+
args.abort("PROMPT_REFERENCES_UNAVAILABLE_TOOL", {
50+
metadata: { stepNumber: args.stepNumber, toolNames: unavailableToolReferences }
51+
});
52+
}
53+
4454
if (promptTokens > inputBudget) {
4555
args.abort("CONTEXT_FINAL_PROMPT_EXCEEDS_BUDGET", {
4656
metadata: { inputBudget, promptTokens, stepNumber: args.stepNumber }
@@ -50,3 +60,37 @@ export class MastraProviderPromptGuardProcessor implements Processor<"provider-p
5060
return undefined;
5161
}
5262
}
63+
64+
const UNAVAILABLE_SYSTEM_TOOL_NAMES = ["updateWorkingMemory", "setWorkingMemory", "update-working-memory"] as const;
65+
66+
const findUnavailableToolReferences = (prompt: unknown): string[] => {
67+
const systemText = collectSystemText(prompt).join("\n");
68+
return UNAVAILABLE_SYSTEM_TOOL_NAMES.filter((toolName) => systemText.includes(toolName));
69+
};
70+
71+
const collectSystemText = (value: unknown): string[] => {
72+
if (!Array.isArray(value)) {
73+
return [];
74+
}
75+
return value.flatMap((entry) => {
76+
if (!isRecord(entry) || entry.role !== "system") {
77+
return [];
78+
}
79+
return contentText(entry.content);
80+
});
81+
};
82+
83+
const contentText = (content: unknown): string[] => {
84+
if (typeof content === "string") {
85+
return [content];
86+
}
87+
if (!Array.isArray(content)) {
88+
return [];
89+
}
90+
return content.flatMap((part) =>
91+
isRecord(part) && typeof part.text === "string" ? [part.text] : []
92+
);
93+
};
94+
95+
const isRecord = (value: unknown): value is Record<string, unknown> =>
96+
typeof value === "object" && value !== null;

packages/agent-runtime/src/index.ts

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
taskWriteTool
99
} from "@mastra/core/harness";
1010
import { Mastra } from "@mastra/core/mastra";
11+
import { WorkingMemory } from "@mastra/core/processors";
1112
import { createSkillTools, createWorkspaceTools } from "@mastra/core/workspace";
1213
import type { Message } from "@ag-ui/core";
1314
import type { ArtifactService } from "@datafoundry/artifacts";
@@ -47,6 +48,7 @@ import {
4748
import {
4849
type TaskStateRuntime
4950
} from "./memory/task-state-runtime.js";
51+
import { CONVERSATION_WORKING_MEMORY_CONFIG } from "./memory/conversation-memory-bridge.js";
5052
import type { RuntimeContextSource } from "./context/source/runtime-context-source.js";
5153
import { GoalRuntimeAdapter, type GoalRequest } from "./memory/goal-runtime-adapter.js";
5254
import { createDataFoundryToolRegistry } from "./tools/data-tools.js";
@@ -131,10 +133,16 @@ export {
131133
resolveWorkspaceRoot
132134
} from "./tools/workspace-factory.js";
133135
export { resolvePythonRuntime } from "./tools/python-runtime.js";
134-
export { projectWorkspaceObservation } from "./context/tool-observation/adapters/workspace-tool-observation-adapters.js";
136+
export {
137+
projectWorkspaceObservation
138+
} from "./context/tool-observation/adapters/workspace-tool-observation-adapters.js";
135139
export { shouldReuseRecordedFileArtifactForPublish } from "./tools/artifact-publish-policy.js";
136140
export { createDataFoundryToolRegistry, type ToolRegistry } from "./tools/data-tools.js";
137-
export { GoalRuntimeAdapter, type GoalRequest, type GoalSnapshot } from "./memory/goal-runtime-adapter.js";
141+
export {
142+
GoalRuntimeAdapter,
143+
type GoalRequest,
144+
type GoalSnapshot
145+
} from "./memory/goal-runtime-adapter.js";
138146
export { createContextItem, hashContextContent } from "./context/inventory/context-item.js";
139147
export type {
140148
ContextItem,
@@ -253,7 +261,7 @@ export const createDataFoundry = async (
253261
});
254262
}
255263
mkdirSync(join(skillCacheDir, "skills"), { recursive: true });
256-
// 绑定到本次 session 的工作区:LocalFilesystem + LocalSandbox(macOS seatbelt / Linux bubblewrap 隔离)
264+
// 绑定到本次 session 的工作区:LocalFilesystem + LocalSandbox。
257265
// createDataFoundry 每次 run 都调用,直接闭包捕获 runContext,不依赖下游 requestContext 注入。
258266
const runWorkspace = createRunWorkspace({
259267
runContext: input.runContext,
@@ -307,6 +315,9 @@ export const createDataFoundry = async (
307315
runState: contextRunState,
308316
...(input.taskStateRuntime ? { taskStateRuntime: input.taskStateRuntime } : {})
309317
});
318+
const readOnlyWorkingMemoryProcessor = input.taskStateRuntime
319+
? await createReadOnlyWorkingMemoryProcessor(input.taskStateRuntime)
320+
: undefined;
310321
const dashScopePromptCompat = new DashScopePromptCompatProcessor(
311322
shouldApplyDashScopePromptCompat(input.modelProvider.kind),
312323
);
@@ -439,7 +450,11 @@ export const createDataFoundry = async (
439450
// Workspace remains attached for execution context, while auto-injection is disabled above.
440451
// Explicitly created tools are wrapped by the same governed execution boundary as every other tool.
441452
workspace: runWorkspace.workspace,
442-
inputProcessors: [...mastraContextProcessors.inputProcessors, dashScopePromptCompat],
453+
inputProcessors: [
454+
...(readOnlyWorkingMemoryProcessor ? [readOnlyWorkingMemoryProcessor] : []),
455+
...mastraContextProcessors.inputProcessors,
456+
dashScopePromptCompat
457+
],
443458
outputProcessors: mastraContextProcessors.outputProcessors,
444459
defaultOptions: {
445460
maxSteps: AGENT_MAX_STEPS,
@@ -552,22 +567,34 @@ type MaterializedWorkspaceAttachment = {
552567
const buildAgentInstructions = (input: AgentInstructionsInput): string => {
553568
const { runContext: context, collaborationToolsEnabled, commandExecutionEnabled, taskToolsEnabled } = input;
554569
const enabled = (name: string): boolean => input.toolNames.includes(name);
570+
const publishArtifactEnabled = enabled("publish_artifact");
571+
const promoteWorkspaceFileEnabled = enabled("promote_workspace_file");
555572
const dataTools = ["list_data_sources", "inspect_schema", "preview_table", "run_sql_readonly"].filter(enabled);
556573
const toolGroups: string[] = dataTools.length > 0 ? [`Data tools: ${dataTools.join(", ")}.`] : [];
557574
if ((context.enabled_knowledge_ids?.length ?? 0) > 0 && enabled("retrieve_knowledge")) {
558575
toolGroups.push("Knowledge tools: retrieve_knowledge.");
559576
}
560-
if (enabled("publish_artifact")) {
577+
if (publishArtifactEnabled) {
561578
toolGroups.push("Artifact tools: publish_artifact.");
562579
}
563580
const workspaceAssetTools = ["list_workspace_files", "read_workspace_file", "promote_workspace_file"]
564581
.filter(enabled);
565582
if (workspaceAssetTools.length > 0) {
583+
const sessionProducerTools = [
584+
...(enabled("write_file") ? ["write_file"] : []),
585+
...(enabled("execute_command") ? ["execute_command"] : [])
586+
];
587+
const sessionProducerText = sessionProducerTools.length > 0
588+
? `New files you write (${sessionProducerTools.join(" / ")}) are session-scoped — only this session sees them. `
589+
: "New files in the session workspace are session-scoped — only this session sees them. ";
566590
toolGroups.push(
567591
`Workspace asset tools: ${workspaceAssetTools.join(", ")}. `
568-
+ "New files you write (write_file / execute_command) are session-scoped — only this session sees them. "
592+
+ sessionProducerText
569593
+ "list_workspace_files / read_workspace_file read the cross-session workspace root (shared across your "
570-
+ "sessions, read-only). promote_workspace_file copies a session file into that cross-session root."
594+
+ "sessions, read-only). "
595+
+ (promoteWorkspaceFileEnabled
596+
? "promote_workspace_file copies a session file into that cross-session root."
597+
: "Use only the available workspace asset tools listed above.")
571598
);
572599
}
573600
const workspaceTools = [
@@ -584,18 +611,21 @@ const buildAgentInstructions = (input: AgentInstructionsInput): string => {
584611
toolGroups.push(`Workspace tools (session-isolated directory): ${workspaceTools.join(", ")}. `
585612
+ "Files you write stay within this session's workspace and can be reused by later runs in the same session. "
586613
+ (enabled("execute_command")
587-
? (input.pythonRuntimeAvailable
614+
? (input.pythonRuntimeAvailable
588615
? "execute_command runs in a sandbox without network access. Use `python3.12 script.py` for local analysis; "
589-
+ "numpy, pandas, matplotlib, and scikit-learn are available from the project venv. Write scripts with write_file first, "
616+
+ "numpy, pandas, matplotlib, and scikit-learn are available from the project venv. "
617+
+ "Write scripts with write_file first, "
590618
+ "save charts with plt.savefig(), and persist outputs as workspace files. "
591619
+ "Do not use execute_command for external services or direct database access."
592620
: "execute_command runs in a sandbox without network access. Use it only for local transforms, charts, "
593621
+ "or exports; never use it to access external services.")
594-
: "execute_command is disabled this run; rely on the available data and file tools only."));
622+
: "Command execution is disabled this run; rely on the available data and file tools only."));
595623
}
596624
if (input.workspaceAttachments.length > 0) {
597625
toolGroups.push(`Uploaded workspace input files: ${input.workspaceAttachments
598-
.map((file) => `${file.path} (file_id=${file.file_id}, mime=${file.mime_type ?? "unknown"}, size=${file.size_bytes})`)
626+
.map((file) =>
627+
`${file.path} (file_id=${file.file_id}, mime=${file.mime_type ?? "unknown"}, size=${file.size_bytes})`
628+
)
599629
.join("; ")}.`);
600630
}
601631
// R-019: per-run @ mentions — focus signal, not a narrowing. The agent is told which
@@ -677,18 +707,22 @@ const buildAgentInstructions = (input: AgentInstructionsInput): string => {
677707
input.selectedSkills.map((skill) => skill.name).join(", ")
678708
}.`
679709
: "";
710+
const skillScriptPolicy = enabled("execute_command")
711+
? " Scripts from skills may be executed only through approved workspace tools such as execute_command."
712+
: " Treat scripts from skills as reference material this run because command execution is unavailable.";
680713
policies.push(
681714
"Use skills as task guidance, not as executable tools. skill_search may search the full shared skill cache; "
682715
+ "a search result does not by itself mean the skill was selected for this run."
683716
+ selectedSkillHint
684717
+ " When the task matches an available skill, call "
685718
+ "skill_search or skill to load its instructions, then use normal approved tools to act. "
686719
+ "Use skill_read for references, scripts, or assets that belong to a relevant loaded skill. "
687-
+ "Scripts from skills may be executed only through approved workspace tools such as execute_command."
720+
+ skillScriptPolicy
688721
);
689722
}
690723
if (enabled("inspect_schema") && (enabled("run_sql_readonly") || enabled("preview_table"))) {
691-
policies.push("Inspect before you query, then reuse the schema_id. inspect_schema returns a schema_id token that authorizes "
724+
policies.push("Inspect before you query, then reuse the schema_id. "
725+
+ "inspect_schema returns a schema_id token that authorizes "
692726
+ "run_sql_readonly and preview_table; pass it as their schema_id argument. The first SQL or preview against a "
693727
+ "datasource must be preceded by an inspect_schema for it; without a valid schema_id the tools fail with "
694728
+ "SCHEMA_REQUIRED. The token enforces inspect-before-query ordering within this run; Data Gateway remains the "
@@ -698,12 +732,15 @@ const buildAgentInstructions = (input: AgentInstructionsInput): string => {
698732
if (enabled("run_sql_readonly")) {
699733
policies.push("Write read-only SQL only. Generate SELECT or WITH queries and run them through run_sql_readonly. "
700734
+ "Do not attempt writes, DDL, or multi-statement scripts through SQL"
701-
+ ". Never use execute_command or direct database clients to bypass Data Gateway."
735+
+ (enabled("execute_command")
736+
? ". Never use execute_command or direct database clients to bypass Data Gateway."
737+
: ". Never use direct database clients to bypass Data Gateway.")
702738
);
703739
}
704740
policies.push(
705741
"Reply in the same natural language as the user's latest request. If the user mixes languages, use the dominant "
706-
+ "language from the request. Keep SQL, code, table names, column names, and other technical identifiers unchanged."
742+
+ "language from the request. Keep SQL, code, table names, column names, and other technical identifiers "
743+
+ "unchanged."
707744
);
708745
policies.push(
709746
`Respect limits. This run allows at most ${AGENT_MAX_STEPS} steps and `
@@ -712,21 +749,33 @@ const buildAgentInstructions = (input: AgentInstructionsInput): string => {
712749
+ "Prefer one focused query per datasource before refining."
713750
);
714751
if (commandExecutionEnabled) {
752+
const artifactPublishPolicy = publishArtifactEnabled
753+
? "Call publish_artifact for files that should be exposed to the client as artifacts. "
754+
: "Do not claim a file was exposed as a client-visible artifact unless a tool result confirms it. ";
755+
const workspacePromotionPolicy = promoteWorkspaceFileEnabled
756+
? "Call promote_workspace_file only to lift a session workspace file into a cross-session reusable asset "
757+
+ "(files in the same session are already retained across runs; do not promote merely to reuse within this "
758+
+ "session)."
759+
: "";
715760
policies.push(
716761
"Persist derived artifacts in the workspace. When analysis produces exports, charts, or transformed datasets, "
717762
+ "write them as files via write_file so they are retained with the session, rather than only echoing them in "
718-
+ "the final message. Call publish_artifact for files that should be exposed to the client as artifacts. "
763+
+ "the final message. "
764+
+ artifactPublishPolicy
719765
+ "Do not invent download URLs, link text, or UI placement such as 'click the link below'; the client renders "
720766
+ "download controls from artifact events and file APIs. "
721-
+ "Call promote_workspace_file only to lift a session workspace file into a cross-session reusable asset "
722-
+ "(files in the same session are already retained across runs; do not promote merely to reuse within this session)."
767+
+ workspacePromotionPolicy
723768
);
724769
if (input.pythonRuntimeAvailable) {
725770
policies.push(
726-
"For Python analysis, prefer write_file to create a .py script, then execute_command with `python3.12 <script>`. "
771+
"For Python analysis, prefer write_file to create a .py script, then execute_command with "
772+
+ "`python3.12 <script>`. "
727773
+ "Use pandas for tabular work, matplotlib with plt.savefig() for charts (no GUI display), and scikit-learn "
728-
+ "for modeling. Export CSV/JSON/PNG artifacts to the session workspace and publish_artifact when the user "
729-
+ "should download results."
774+
+ "for modeling. "
775+
+ (publishArtifactEnabled
776+
? "Export CSV/JSON/PNG artifacts to the session workspace and publish_artifact when the user "
777+
+ "should download results."
778+
: "Export CSV/JSON/PNG files to the session workspace when the user should reuse results.")
730779
);
731780
}
732781
}
@@ -775,6 +824,25 @@ const selectToolsByPolicy = <TTool>(
775824
));
776825
};
777826

827+
const createReadOnlyWorkingMemoryProcessor = async (
828+
runtime: TaskStateRuntime
829+
): Promise<WorkingMemory | undefined> => {
830+
const memoryStore = await runtime.storage.getStore("memory");
831+
if (!memoryStore) {
832+
return undefined;
833+
}
834+
return new WorkingMemory({
835+
storage: memoryStore,
836+
readOnly: true,
837+
scope: CONVERSATION_WORKING_MEMORY_CONFIG.workingMemory.scope,
838+
template: {
839+
format: "markdown",
840+
content: CONVERSATION_WORKING_MEMORY_CONFIG.workingMemory.template
841+
},
842+
templateProvider: runtime.memory
843+
});
844+
};
845+
778846
const requireFileAssetService = (service: FileAssetService | undefined): FileAssetService => {
779847
if (!service) {
780848
throw new Error("SKILL_FILE_ASSET_SERVICE_REQUIRED");
@@ -986,7 +1054,14 @@ const throwIfAborted = (signal?: AbortSignal | undefined): void => {
9861054
};
9871055

9881056
/** List files under a directory (one level) relative to the workspace root, read-only. */
989-
const listWorkspaceFiles = (dirPath: string): Array<{ path: string; name: string; size_bytes: number; is_directory: boolean }> => {
1057+
type WorkspaceFileEntry = {
1058+
is_directory: boolean;
1059+
name: string;
1060+
path: string;
1061+
size_bytes: number;
1062+
};
1063+
1064+
const listWorkspaceFiles = (dirPath: string): WorkspaceFileEntry[] => {
9901065
let entries: import("node:fs").Dirent[];
9911066
try {
9921067
entries = readdirSync(dirPath, { withFileTypes: true });

scripts/smoke-task-state.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ try {
7373
const configuredTools = await configured.agent.listTools();
7474
const processorIds = (await configured.agent.listConfiguredInputProcessors()).map((item) => item.id);
7575
assert("task_write" in configuredTools && "task_check" in configuredTools, "agent should expose builtin task tools");
76+
assert(
77+
!("updateWorkingMemory" in configuredTools),
78+
"agent.listTools should not expose read-only WorkingMemory writes"
79+
);
80+
assert(
81+
processorIds.includes("working-memory"),
82+
"agent should explicitly install a read-only WorkingMemory processor"
83+
);
84+
const configuredInputProcessors = await configured.agent.listConfiguredInputProcessors();
85+
const memoryProcessors = await secondRuntime.memory.getInputProcessors(configuredInputProcessors);
86+
assert(
87+
!memoryProcessors.some((item) => item.id === "working-memory"),
88+
"configured read-only WorkingMemory processor should suppress Mastra's writable default"
89+
);
7690
assert(processorIds.includes("task-state-context"), "agent should include durable task context injection");
7791
await configured.destroyWorkspace();
7892
await secondRuntime.close();

0 commit comments

Comments
 (0)