Skip to content

Commit cd5c2a9

Browse files
committed
feat: improve local run, deploy, and invoke. Add temp eval infra for testing
1 parent 8ddef08 commit cd5c2a9

7 files changed

Lines changed: 210 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,3 +359,4 @@ dashboard/**/dist/
359359

360360
# Local vally eval outputs
361361
results/
362+
tmp/

plugin/skills/microsoft-foundry/foundry-agent/create/references/local-run.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Use this when iterating on a hosted agent before deploying.
88
> AZURE_AI_MODEL_DEPLOYMENT_NAME=<model-deployment-name>
99
> ```
1010
> If you already ran `azd provision`, extract these from `azd env get-values`.
11+
>
12+
> **Critical: keep `.env` and `azd env` in sync.** `azd ai agent run` injects the active `azd env` values into the agent process before Python loads `.env`. Many samples use `load_dotenv(override=False)`, so an existing process environment value wins over `.env`. If you change the project endpoint or model deployment, update both `.env` and `azd env`:
13+
> ```bash
14+
> azd env set FOUNDRY_PROJECT_ENDPOINT "https://<account>.services.ai.azure.com/api/projects/<project>"
15+
> azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME "<model-deployment-name>"
16+
> azd env get-values
17+
> ```
18+
> A stale `AZURE_AI_MODEL_DEPLOYMENT_NAME` in `azd env` can make local run call the wrong deployment even when `.env` is correct, commonly surfacing as a Foundry responses API `404 Not Found`.
1119
1220
## Start the agent locally
1321
@@ -19,7 +27,7 @@ What this does:
1927

2028
1. Resolves the agent service from `azure.yaml` (auto-picks when only one exists).
2129
2. Detects the project type (Python, .NET, Node.js) from files in the service source dir.
22-
3. Installs dependencies if needed (`uv pip install -e .`, `npm install`, `dotnet restore`).
30+
3. Installs dependencies if needed (`pip install -r requirements.txt`, `npm install`, `dotnet restore`).
2331
4. Starts the agent in the foreground on `localhost:8088` (default).
2432
5. Opens **Agent Inspector** in your browser (unless `--no-inspector`).
2533

plugin/skills/microsoft-foundry/foundry-agent/deploy/deploy.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ azd deploy --no-prompt
164164
165165
Each env has its own `AGENT_<SVC>_*` vars.
166166
167-
## Common failure modes -- Hosted
167+
## Common failure -- Hosted
168168
169169
| Error | Fix |
170170
|-------|-----|
@@ -176,6 +176,7 @@ Each env has its own `AGENT_<SVC>_*` vars.
176176
| `container registry endpoint not found` | ACR not configured. Use `azd env set AZURE_CONTAINER_REGISTRY_ENDPOINT <url>`, or switch to direct code deployment. |
177177
| Agent version poll times out | Image still building; retry `azd ai agent show` after a minute. |
178178
| `session_not_ready` (424) | Cold start — wait 30-60 seconds and retry. For direct code deployments, first invocation installs dependencies. Use `1` CPU / `2Gi` memory minimum. **Also verify:** (1) a capability host exists on the Foundry account (see Step 2 above), (2) the agent's managed identity has `Cognitive Services User` role on the Foundry account — missing capability host or role are the most common causes of persistent `session_not_ready`. See [direct-code-deployment Task 8-9](references/direct-code-deployment.md) and [invoke](../invoke/invoke.md). |
179+
| `could not resolve agent service in azd project: no azure.ai.agent service named '<agentName>' found in azure.yaml` from `azd ai agent invoke` | Name mismatch. Update the agent name to the deployed agent name. |
179180
| `subscription quota exceeded` | Ask user to request quota; don't auto-retry. |
180181
| Bicep deploy errors | Forward `error.details[]` verbatim to the user. |
181182
| `RoleAssignmentUpdateNotPermitted` during provision | A role assignment already exists but conflicts. Check for existing role assignments with `az role assignment list --scope <resource-scope>`. The provision may have succeeded for all resources except RBAC — verify with `azd ai project show` and manually assign the `Cognitive Services User` role to the agent identity if needed. |

plugin/skills/microsoft-foundry/foundry-agent/deploy/references/direct-code-deployment.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ Do not send `x-ms-agent-name` on `POST /agents/<agent-name>/versions` or `POST /
315315

316316
Update agent and create version are idempotent on zip SHA-256 plus agent definition. If both are unchanged from the latest version, the service can return the existing version instead of creating a duplicate. To force a new version, change the zip contents or definition.
317317

318+
If the write response contains `versions.latest`, use `versions.latest.version`, `versions.latest.status`, and `versions.latest.instance_identity.principal_id`. Do not poll `/versions/None`; if no version can be extracted, list versions first and pick the newest returned version.
319+
318320
Other useful REST operations:
319321

320322
| Purpose | Method and endpoint | Notes |

plugin/skills/microsoft-foundry/foundry-agent/invoke/invoke.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Use `session_delete` to release compute resources when done. Undeleted sessions
120120
| Hosted agent not active | Version still provisioning or failed | Check version status via `agent_get` |
121121
| Session not found | Invalid ID or expired | Create new session with `session_create` |
122122
| `424 FailedDependency` or `session_not_ready` | Hosted agent session is still warming up or readiness has not completed | Wait 15-30 seconds, check `session_logstream` if needed, then retry `agent_invoke` with the same `sessionId` if one was returned; if no `sessionId` was returned, retry `session_create`. If this persists across 3+ retries (with exponential backoff: 15s, 30s, 60s), the container likely cannot start within the readiness probe deadline — redeploy with higher CPU/memory (recommended minimum: `1` CPU / `2Gi` for direct-code deployments). Also verify the model deployment name is correct via `model_deployment_get`. |
123+
| `could not resolve agent service in azd project: no azure.ai.agent service named '<agentName>' found in azure.yaml` from `azd ai agent invoke` | Name mismatch. | Update the agent name to the deployed agent name. |
123124
| Invocation failed | Model error, timeout, or invalid input | Check agent logs, verify model deployment |
124125
| Invocations schema mismatch | Request body does not match what the agent expects | Inspect agent's route handler or API docs for the correct JSON schema; do not guess |
125126
| File operation failed | Session not active or invalid path | Verify session with `session_get` |

tests/utils/agent-runner.ts

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,22 @@ export interface AgentRunConfig {
147147
takeScreenshot?: {
148148
predicate: (agentMetadata: AgentMetadata) => boolean
149149
};
150+
151+
/**
152+
* Optional sink for streaming redacted agent output while a run is in progress.
153+
* Intended for long-running Vally tests where waiting for final reports is too slow.
154+
*/
155+
realtimeOutputSink?: AgentRealtimeOutputSink;
150156
}
151157

152158
interface KeywordOptions {
153159
caseSensitive?: boolean;
154160
}
155161

162+
export interface AgentRealtimeOutputSink {
163+
write: (chunk: string) => void | Promise<void>;
164+
}
165+
156166
/** Tracks resources that need cleanup after each test */
157167
interface RunnerCleanup {
158168
session?: CopilotSession;
@@ -163,6 +173,162 @@ interface RunnerCleanup {
163173
agentMetadata?: AgentMetadata;
164174
}
165175

176+
function stringifyForLog(value: unknown): string {
177+
if (value === undefined || value === null) {
178+
return "";
179+
}
180+
181+
if (typeof value === "string") {
182+
return value;
183+
}
184+
185+
try {
186+
return JSON.stringify(value, null, 2);
187+
} catch {
188+
return String(value);
189+
}
190+
}
191+
192+
function truncateForLog(value: string, maxLength = 4000): string {
193+
if (value.length <= maxLength) {
194+
return value;
195+
}
196+
197+
return `${value.slice(0, maxLength)}... (truncated)`;
198+
}
199+
200+
function createRealtimeOutputFlusher(sink: AgentRealtimeOutputSink | undefined) {
201+
const messageIdsWithDeltas = new Set<string>();
202+
const toolStartedAtByCallId = new Map<string, string>();
203+
let pendingWrite = Promise.resolve();
204+
205+
function enqueue(chunk: string): void {
206+
if (!sink || !chunk) {
207+
return;
208+
}
209+
210+
const redacted = redactSecrets(chunk);
211+
pendingWrite = pendingWrite
212+
.then(async () => {
213+
await sink.write(redacted);
214+
})
215+
.catch((error: unknown) => {
216+
if (process.env.DEBUG) {
217+
console.error("Failed to write realtime agent output:", error);
218+
}
219+
});
220+
}
221+
222+
function flushEvent(event: SessionEvent): void {
223+
const timestamp = event.timestamp ?? new Date().toISOString();
224+
225+
if (event.type === "assistant.message_delta") {
226+
const messageId = event.data.messageId as string | undefined;
227+
const deltaContent = event.data.deltaContent as string | undefined;
228+
if (!deltaContent) {
229+
return;
230+
}
231+
232+
if (messageId && !messageIdsWithDeltas.has(messageId)) {
233+
messageIdsWithDeltas.add(messageId);
234+
enqueue(`\n[${timestamp}] assistant.message_delta\n`);
235+
}
236+
enqueue(deltaContent);
237+
return;
238+
}
239+
240+
if (event.type === "assistant.message") {
241+
const messageId = event.data.messageId as string | undefined;
242+
const content = event.data.content as string | undefined;
243+
if (!content) {
244+
return;
245+
}
246+
247+
if (messageId && messageIdsWithDeltas.has(messageId)) {
248+
enqueue("\n\n");
249+
return;
250+
}
251+
252+
enqueue(`\n[${timestamp}] assistant.message\n${content}\n\n`);
253+
return;
254+
}
255+
256+
if (event.type === "tool.execution_start") {
257+
const toolName = event.data.toolName as string | undefined;
258+
const toolCallId = event.data.toolCallId as string | undefined;
259+
if (toolCallId) {
260+
toolStartedAtByCallId.set(toolCallId, event.timestamp);
261+
}
262+
263+
const args = truncateForLog(stringifyForLog(event.data.arguments));
264+
enqueue([
265+
`\n[${timestamp}] tool.execution_start ${toolName ?? "unknown"}`,
266+
toolCallId ? `toolCallId: ${toolCallId}` : undefined,
267+
args ? `arguments:\n${args}` : undefined,
268+
"",
269+
].filter((line) => line !== undefined).join("\n"));
270+
return;
271+
}
272+
273+
if (event.type === "tool.execution_complete") {
274+
const toolCallId = event.data.toolCallId as string | undefined;
275+
const startedAt = toolCallId ? toolStartedAtByCallId.get(toolCallId) : undefined;
276+
const durationMs = startedAt
277+
? new Date(timestamp).getTime() - new Date(startedAt).getTime()
278+
: undefined;
279+
const success = event.data.success as boolean | undefined;
280+
const payload = success ? event.data.result : event.data.error;
281+
const payloadText = truncateForLog(stringifyForLog(payload));
282+
283+
enqueue([
284+
`\n[${timestamp}] tool.execution_complete`,
285+
toolCallId ? `toolCallId: ${toolCallId}` : undefined,
286+
`success: ${success === true}`,
287+
durationMs !== undefined && Number.isFinite(durationMs) ? `durationMs: ${durationMs}` : undefined,
288+
payloadText ? `result:\n${payloadText}` : undefined,
289+
"",
290+
].filter((line) => line !== undefined).join("\n"));
291+
return;
292+
}
293+
294+
if (event.type === "skill.invoked") {
295+
const skillName = event.data.name as string | undefined;
296+
const skillPath = event.data.path as string | undefined;
297+
enqueue([
298+
`\n[${timestamp}] skill.invoked ${skillName ?? "unknown"}`,
299+
skillPath ? `path: ${skillPath}` : undefined,
300+
"",
301+
].filter((line) => line !== undefined).join("\n"));
302+
return;
303+
}
304+
305+
if (event.type === "session.skills_loaded") {
306+
const skills = event.data.skills as Array<{
307+
name?: string;
308+
source?: string;
309+
enabled?: boolean;
310+
path?: string;
311+
}>;
312+
const skillLines = skills
313+
.map((skill) => [
314+
`- ${skill.name ?? "unknown"}`,
315+
skill.source ? `source=${skill.source}` : undefined,
316+
skill.enabled !== undefined ? `enabled=${skill.enabled}` : undefined,
317+
skill.path ? `path=${skill.path}` : undefined,
318+
].filter((part) => part !== undefined).join(" "))
319+
.join("\n");
320+
321+
enqueue(`\n[${timestamp}] session.skills_loaded\n${skillLines}\n\n`);
322+
}
323+
}
324+
325+
async function drain(): Promise<void> {
326+
await pendingWrite;
327+
}
328+
329+
return { flushEvent, drain };
330+
}
331+
166332
/**
167333
* Extract file-system paths from the serialized arguments of a tool call that
168334
* reference the given `skillDirectory`. Checks common argument keys
@@ -643,6 +809,7 @@ export function useAgentRunner(agentRunnerConfig: AgentRunnerConfig) {
643809
const FOLLOW_UP_TIMEOUT = config.followUpTimeout ?? 1800000; // 30 minutes by default
644810

645811
let isComplete = false;
812+
const realtimeOutput = createRealtimeOutputFlusher(config.realtimeOutputSink);
646813

647814
const entry: RunnerCleanup = { config };
648815
currentCleanups.push(entry);
@@ -695,6 +862,9 @@ export function useAgentRunner(agentRunnerConfig: AgentRunnerConfig) {
695862
const session = await client.createSession({
696863
model: model,
697864
onPermissionRequest: approveAll,
865+
// Keep integration tests isolated from user-level skills/config such as
866+
// ~/.agents/skills so test runs only exercise the generated output.
867+
enableConfigDiscovery: false,
698868
skillDirectories: noSkills ? [] : [skillDirectory],
699869
disabledSkills: disabledSkills,
700870
mcpServers: {
@@ -724,6 +894,7 @@ export function useAgentRunner(agentRunnerConfig: AgentRunnerConfig) {
724894
}
725895

726896
agentMetadata.events.push(event);
897+
realtimeOutput.flushEvent(event);
727898

728899
if (config.shouldEarlyTerminate?.(agentMetadata)) {
729900
isComplete = true;
@@ -847,6 +1018,7 @@ export function useAgentRunner(agentRunnerConfig: AgentRunnerConfig) {
8471018
if (!isTest()) {
8481019
await cleanup();
8491020
}
1021+
await realtimeOutput.drain();
8501022
}
8511023
}
8521024

@@ -862,6 +1034,24 @@ function buildShareFilePath(testName: string): string {
8621034
return path.join(testCaseArtifactsDir, `agent-metadata-${new Date().toISOString().replace(/[:.]/g, "-")}.md`);
8631035
}
8641036

1037+
export function createRealtimeLogSink(testName: string): AgentRealtimeOutputSink & { filePath: string } {
1038+
const testCaseArtifactsDir = buildTestCaseDirPath(testName);
1039+
fs.mkdirSync(testCaseArtifactsDir, { recursive: true });
1040+
const filePath = path.join(testCaseArtifactsDir, "agent-realtime.log");
1041+
fs.writeFileSync(
1042+
filePath,
1043+
`# Agent realtime log\n\nStarted: ${new Date().toISOString()}\n\n`,
1044+
"utf-8",
1045+
);
1046+
1047+
return {
1048+
filePath,
1049+
write(chunk: string): void {
1050+
fs.appendFileSync(filePath, chunk, "utf-8");
1051+
},
1052+
};
1053+
}
1054+
8651055
export async function createMarkdownReport(testName: string, config: AgentRunConfig, agentMetadata: AgentMetadata): Promise<void> {
8661056
writeMarkdownReport(testName, config, agentMetadata);
8671057
}
@@ -1074,4 +1264,4 @@ function sanitizeFileName(name: string): string {
10741264
.replace(/-+/g, "-") // Collapse multiple dashes
10751265
.replace(/_+/g, "_") // Collapse multiple underscores
10761266
.substring(0, 200); // Limit length
1077-
}
1267+
}

tests/vally/vally-executor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Executor, ExecutorOptions, ExecutorRegistry, Stimulus, Trajectory, TrajectoryEvent } from "@microsoft/vally";
22
import { computeMetrics } from "@microsoft/vally";
33
import type { AgentMetadata, AgentRunConfig } from "../utils/agent-runner.ts";
4-
import { useAgentRunner, createMarkdownReport } from "../utils/agent-runner.ts";
4+
import { useAgentRunner, createMarkdownReport, createRealtimeLogSink } from "../utils/agent-runner.ts";
55
import { listSkills } from "../utils/skill-loader.ts";
66
import { getEarlyTerminateCondition, getFollowUp, getSkillName, getSystemPrompt, getTakeScreenshotCondition } from "./tag-helpers.ts";
77
import { normalizeTestName } from "./utils.ts";
@@ -31,6 +31,8 @@ export class IntegrationTestAgentRunner implements Executor {
3131
const systemPrompt = getSystemPrompt(tags);
3232
const { takeScreenshot } = getTakeScreenshotCondition(tags);
3333
const timeout = options.timeout;
34+
const realtimeLogSink = createRealtimeLogSink(normalizedTestName);
35+
console.log(`Agent realtime log: ${realtimeLogSink.filePath}`);
3436

3537
const runConfig: AgentRunConfig = {
3638
workspace: workDir,
@@ -42,6 +44,7 @@ export class IntegrationTestAgentRunner implements Executor {
4244
systemPrompt: systemPrompt,
4345
followUpTimeout: timeout,
4446
takeScreenshot: takeScreenshot,
47+
realtimeOutputSink: realtimeLogSink,
4548
// Always make our agent runner preserve workspace.
4649
// vally will delete the test workspace by default.
4750
preserveWorkspace: true

0 commit comments

Comments
 (0)