Skip to content
Open
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
8 changes: 4 additions & 4 deletions ts/packages/agentRpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export async function createAgentRpcClient(
actionContextId: actionContextMap.getId(actionContext),
activityContext: actionContext.activityContext,
isFromReasoningLoop: actionContext.isFromReasoningLoop,
workingDirectory: actionContext.workingDirectory,
...getContextParam(actionContext.sessionContext),
});
} finally {
Expand All @@ -265,15 +266,14 @@ export async function createAgentRpcClient(
}
async function withActionContextAsync<T>(
actionContext: ActionContext<ShimContext>,
fn: (contextParams: {
actionContextId: number;
isFromReasoningLoop: boolean;
}) => Promise<T>,
fn: (contextParams: ActionContextParams) => Promise<T>,
) {
try {
return await fn({
actionContextId: actionContextMap.getId(actionContext),
activityContext: actionContext.activityContext,
isFromReasoningLoop: actionContext.isFromReasoningLoop,
workingDirectory: actionContext.workingDirectory,
...getContextParam(actionContext.sessionContext),
});
} finally {
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentRpc/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,7 @@ export function createAgentRpcServer(
streamingContext: undefined,
activityContext: param.activityContext,
isFromReasoningLoop: param.isFromReasoningLoop ?? false,
workingDirectory: param.workingDirectory,
get abortSignal() {
return abortController.signal;
},
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentRpc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ export type ActionContextParams = ContextParams & {
actionContextId: number;
activityContext: ActivityContext | undefined;
isFromReasoningLoop: boolean;
workingDirectory: string | undefined;
};

export type OptionsFunctionCallBack = {
Expand Down
84 changes: 84 additions & 0 deletions ts/packages/agentRpc/test/actionContext.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type {
ActionContext,
AppAgent,
SessionContext,
} from "@typeagent/agent-sdk";
import { createAgentRpcClient } from "../src/client.js";
import {
createChannelProviderAdapter,
type ChannelProviderAdapter,
} from "../src/common.js";
import { createAgentRpcServer } from "../src/server.js";

describe("agent action context RPC", () => {
test("propagates workingDirectory to the out-of-process agent", async () => {
let clientProvider: ChannelProviderAdapter;
let serverProvider: ChannelProviderAdapter;
clientProvider = createChannelProviderAdapter(
"test-client",
(message, callback) => {
queueMicrotask(() => serverProvider.notifyMessage(message));
callback?.(null);
},
);
serverProvider = createChannelProviderAdapter(
"test-server",
(message, callback) => {
queueMicrotask(() => clientProvider.notifyMessage(message));
callback?.(null);
},
);

let receivedWorkingDirectory: string | undefined;
const serverAgent: AppAgent = {
initializeAgentContext: async () => ({}),
executeAction: async (_action, context) => {
receivedWorkingDirectory = context.workingDirectory;
return undefined;
},
};
const server = createAgentRpcServer(
"test",
serverAgent,
serverProvider,
);
const clientAgent = await createAgentRpcClient(
"test",
clientProvider,
server.agentInterface,
);

try {
const agentContext = await clientAgent.initializeAgentContext?.();
const sessionContext = {
agentContext,
sessionContextId: "rpc-working-directory-test",
} as SessionContext<unknown>;
const actionContext = {
sessionContext,
workingDirectory: "C:\\host-authorized-workspace",
isFromReasoningLoop: false,
} as ActionContext<unknown>;

await clientAgent.executeAction?.(
{
schemaName: "test",
actionName: "test",
parameters: {},
},
actionContext,
);

expect(receivedWorkingDirectory).toBe(
"C:\\host-authorized-workspace",
);
} finally {
server.closeFn();
clientProvider.notifyDisconnected();
serverProvider.notifyDisconnected();
}
});
});
3 changes: 3 additions & 0 deletions ts/packages/agentSdk/src/agentInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ export interface ActionContext<T = void> {
// to execute immediately or redirect back to the reasoning loop.
readonly isFromReasoningLoop: boolean;

// Absolute filesystem root authorized by the host for this action.
readonly workingDirectory?: string | undefined;

// queue up toggle transient agent to be executed at the end of the commands
queueToggleTransientAgent(
agentName: string,
Expand Down
113 changes: 109 additions & 4 deletions ts/packages/agents/markdown/src/agent/markdownActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@ import {
AppAgentInitSettings,
} from "@typeagent/agent-sdk";
import { createActionResult } from "@typeagent/agent-sdk/helpers/action";
import { MarkdownAction } from "./markdownActionSchema.js";
import {
CreateDocumentAction,
MarkdownAction,
} from "./markdownActionSchema.js";
import { DocumentOperation } from "./markdownOperationSchema.js";
import { createMarkdownAgent } from "./translator.js";
import { ChildProcess, fork } from "child_process";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { UICommandResult } from "./ipcTypes.js";
import registerDebug from "debug";
import {
normalizeRelativeDocumentPath,
resolveRealDirectory,
resolveWritableFileWithinRoot,
} from "./pathPolicy.js";

const debug = registerDebug("typeagent:markdown:agent");

Expand All @@ -45,6 +54,8 @@ async function executeMarkdownAction(

type MarkdownActionContext = {
currentFileName?: string | undefined;
currentFilePath?: string | undefined;
currentWorkspaceRoot?: string | undefined;
viewProcess?: ChildProcess | undefined;
localHostPort: number;
// Handle returned by sessionContext.registerPort for the markdown
Expand Down Expand Up @@ -534,12 +545,97 @@ async function getFullMarkdownFilePath(fileName: string, storage: Storage) {
return candidates ? candidates[0] : undefined;
}

async function handleCreateDocument(
action: CreateDocumentAction,
actionContext: ActionContext<MarkdownActionContext>,
): Promise<ActionResult> {
const rawName = action.parameters.name;
const relativeCandidate = normalizeRelativeDocumentPath(rawName);
if (relativeCandidate === undefined) {
throw new Error(
`Document name is not a safe relative path: ${JSON.stringify(rawName)}`,
);
}
const relativeName = relativeCandidate.toLowerCase().endsWith(".md")
? relativeCandidate
: `${relativeCandidate}.md`;

const workingDirectory = actionContext.workingDirectory;
if (workingDirectory === undefined) {
throw new Error(
"Markdown document creation requires a host-authorized working directory",
);
}
const canonicalRoot = resolveRealDirectory(workingDirectory);
if (canonicalRoot === undefined) {
throw new Error(
`Configured workingDirectory is not a real directory: ${workingDirectory}`,
);
}
const absoluteFilePath = resolveWritableFileWithinRoot(
canonicalRoot,
relativeName,
);
if (absoluteFilePath === undefined) {
throw new Error(
`Document name escapes workingDirectory: ${JSON.stringify(rawName)}`,
);
}

const initialContent = action.parameters.content ?? "";
const documentExisted = fs.existsSync(absoluteFilePath);
if (!documentExisted) {
fs.writeFileSync(absoluteFilePath, initialContent, {
encoding: "utf-8",
flag: "wx",
});
} else if (initialContent) {
const existingContent = fs.readFileSync(absoluteFilePath, "utf-8");
if (existingContent) {
throw new Error(
`Document ${relativeName} already contains content`,
);
}
fs.writeFileSync(absoluteFilePath, initialContent, "utf-8");
}

const agentContext = actionContext.sessionContext.agentContext;
agentContext.currentFileName = relativeName;
agentContext.currentFilePath = absoluteFilePath;
agentContext.currentWorkspaceRoot = canonicalRoot;

if (agentContext.viewProcess) {
agentContext.viewProcess.send({
type: "setFile",
filePath: path.basename(absoluteFilePath),
folderPath: path.dirname(absoluteFilePath),
});
}

const actionLabel = documentExisted ? "opened" : "created";
const result = createActionResult(
`Document ${actionLabel} at ${absoluteFilePath}`,
);
result.resultEntity = {
name: relativeName,
type: ["file", "markdown"],
};
result.activityContext = {
activityName: "editingMarkdown",
description: "Editing a Markdown document",
state: {
fileName: relativeName,
},
openLocalView: true,
};
return result;
}

async function handleMarkdownAction(
action: MarkdownAction,
actionContext: ActionContext<MarkdownActionContext>,
) {
let result: ActionResult | undefined = undefined;
const agent = await createMarkdownAgent("GPT_4o");

// Accumulates the LLM token usage consumed while handling this action so
// it can be reported back to the dispatcher as "Action Tokens". The agent
Expand All @@ -549,13 +645,20 @@ async function handleMarkdownAction(
completion_tokens: 0,
total_tokens: 0,
};
agent.tokenUsage = tokenUsage;
const createAgent = async () => {
const agent = await createMarkdownAgent("GPT_4o");
agent.tokenUsage = tokenUsage;
return agent;
};

const storage = actionContext.sessionContext.sessionStorage;

switch (action.actionName) {
case "openDocument":
case "createDocument": {
result = await handleCreateDocument(action, actionContext);
break;
}
case "openDocument": {
if (!action.parameters.name) {
result = createActionResult(
"Document could not be created: no name was provided",
Expand Down Expand Up @@ -600,6 +703,7 @@ async function handleMarkdownAction(
break;
}
case "updateDocument": {
const agent = await createAgent();
debug("Starting updateDocument action in agent process");
result = createActionResult("Updating document ...");

Expand Down Expand Up @@ -740,6 +844,7 @@ async function handleMarkdownAction(
break;
}
case "streamingUpdateDocument": {
const agent = await createAgent();
// Handle streaming AI commands - now unified with regular updateDocument flow
debug(
"Starting streamingUpdateDocument action - using standard translator flow",
Expand Down
2 changes: 2 additions & 0 deletions ts/packages/agents/markdown/src/agent/markdownActionSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type CreateDocumentAction = {
parameters: {
// the name to use for the document
name: string;
// markdown content to write into the new document
content?: string;
};
};

Expand Down
Loading
Loading