ply-agent.h
Agent Harness
ply-agent.h defines a C++ API for interacting with AI agents. Applications create Transcript objects and pass them to Agent objects; the agent's job is to extend the transcript in a logical way. It does this by communicating with a remote inference server and running tools in the local filesystem.
The steps for creating and running a Plywood agent are as follows:
- Create a new
Transcriptobject containing the user's prompt. - Create a new
Agentobject, passing in theTranscript, the desired inference provider and a set of tools for the agent to use. The agent runs in a background thread. - Receive
Transcript::Eventobjects back from theAgent. - As each event comes in, call
applyTranscriptEventand perform any application-specific handling. - Once
Agent::isWorking()returnsfalse, no further events will be received and theAgentcan be safely destroyed.
To send a followup prompt, create a new Transcript object, assign the previous Transcript as its parent, then create another Agent object.
Using ply-agent.h requires linking your project with libcurl for HTTPS support. Instructions for installing libcurl can be found in the documentation for building and running the agent sample.
Agent
The Agent class represents a live conversation with an agent running in a background thread. The background thread executes tool calls automatically and buffers Transcript::Event objects for the application to consume.
A working agent can be cancelled by any thread at any time by calling cancel().
Agent objects can be destroyed at any time as long as there are no racing member function calls from other threads.
Agent::Agent(const Agent::Settings& settings)Constructor. The agent starts running in a background thread.
Agent::Settingshas the following data members:const Transcript* startTranscriptThe transcript used to start the agent. Agent::EndPoint endPointIdentifies the inference provider, protocol and model. Agent::Capabilities capabilitiesSpecifies the system prompt, working directory and available tools. bool enableRawLogEnables raw HTTP-level logging (for debug purposes). Default is false.Agent::Capabilitieshas the following data members:String systemPromptThe system prompt passed to the agent. Set<Owned<ToolDefinition>> toolsThe tools available to the agent. String workingDirThe agent's working directory. Array<String> readableDirsAbsolute directory paths granting recursive read access. Array<String> writableDirsAbsolute directory paths granting recursive write access. These paths should also be present in readableDirs.Array<Transcript::Event> Agent::pollForEvents()Returns all currently buffered events without waiting, or an empty array if no events are buffered. Only one thread is allowed to call
pollForEvents,waitForEventsorwaitForCompletionat a time.Array<Transcript::Event> Agent::waitForEvents(s32 maxTimeInMillis)Waits until at least one event is available, then returns all buffered events. A negative argument waits indefinitely. Only one thread is allowed to call
pollForEvents,waitForEventsorwaitForCompletionat a time.Array<Transcript::Event> Agent::waitForCompletion(s32 maxTimeInMillis)Waits until the agent stops working or the time limit is reached, then returns all buffered events. A negative argument waits indefinitely. Only one thread is allowed to call
pollForEvents,waitForEventsorwaitForCompletionat a time.bool Agent::isWorking()If
true, the agent can still return more events.falsemeans the agent has finished running, the buffer is empty and no more events will arrive. This function can be called by any thread at any time.void Agent::cancel()Stops running the agent. No new events will be generated after this function returns, but any events already buffered remain available for consumption. This function can be called by any thread at any time. If another thread is waiting inside
waitForEventsorwaitForCompletion, that thread will immediately return.If
cancelis called while a tool call is running in the background, the tool call might not stop immediately. Tool calls can continue running briefly aftercancelreturns, but they'll be stopped as soon as possible and won't generate any furtherTranscript::Events.
Transcript
A transcript consists of a sequence of turns, with each turn consisting of a sequence of messages. These concepts are represented by the Transcript, Transcript::Turn and Transcript::Message classes.
Each message in the transcript is associated with a Transcript::Role, which can take any of the following values:
User |
AgentThinking |
Agent |
ToolCall |
Error |
Every Transcript object holds a reference to a parent Transcript object, allowing you to link them together into a graph. Each node in this graph corresponds to a followup prompt sent by the user.
Transcript::Event
Transcript changes are received as a stream of Transcript::Event objects. The agent never modifies the original Transcript object directly; instead, the application must call applyTranscriptEvent for each event it receives.
void applyTranscriptEvent(Transcript* transcript, const Transcript::Event& event)Modifies
transcriptby applying the givenevent.
Applications are free to perform additional application-specific handling in response to each event. To facilitate this, Transcript::Event exposes the following data members:
s64 timeStamp |
The time when the event was created, expressed as a Unix timestamp in microseconds. |
Operation operation |
The kind of change represented by the event. |
Transcript::Role role |
The role of the message started by BeginMessage. Unused by other operations. |
u32 toolCallID |
The index of a tool call within the current transcript. |
String providerToolCallID |
The inference provider's identifier for a tool call. Used internally. |
String text |
The content carried by AppendText, AppendToolResponse or AppendProviderOutputItem events. |
Transcript::Event::Operation can have any of the following values:
BeginTurn |
Appends a new turn for an inference request. |
BeginMessage |
Starts a message with the specified role, finalizing the preceding message if necessary. |
AppendText |
Appends text to the current message. |
AppendToolResponse |
Appends text to the response for the tool call identified by toolCallID. |
EndToolResponse |
Finalizes the response for the tool call identified by toolCallID. |
AppendProviderOutputItem |
Preserves an opaque provider output item for use when replaying the transcript as context. |
SetTokenUsage |
Stores aggregate token usage on the current turn. |
EndTurn |
Finalizes the current message after an inference request completes. |
For every inference request that completes or reports an error, the agent emits EndTurn. If tool calls require another inference request, the agent then emits BeginTurn before any events belonging to that request. A canceled inference is incomplete and does not receive EndTurn.
Tools
To give an agent access to tools in the local filesystem, fill in the capabilities.tools member of Agent::Settings before creating the agent. Several built-in tools are available and custom tools can be easily created.
Built-In Tools
Built-in tools can be created by calling any of the following functions. Each function returns an Owned<ToolDefinition> that can be added to the capabilities.tools member of Agent::Settings.
| Function name | Tool name | Description |
|---|---|---|
createShellTool |
shell |
Runs a command using the system shell after authorization. Not available on iOS. |
createReadTool |
read |
Reads part or all of a file. |
createWriteTool |
write |
Creates or overwrites a file. |
createListDirTool |
list_dir |
Lists the contents of a directory. |
createFindInFilesTool |
find_in_files |
Searches for text in a directory tree. |
createEditTool |
edit |
Edits a file using exact text replacements. |
All built-in tools except shell are designed to respect the directory permissions outlined in Agent::Capabilities. Custom tools must implement their own permission checks. shell tool permissions are implemented by passing a callback to createShellTool, as described in the Tool Monitoring section.
Creating Custom Tools
In addition to the built-in tools, applications are free to create their own tools that integrate more closely with agents. To do so, simply fill in the public data members of ToolDefinition as appropriate.
String name |
The name of the tool, as presented to the agent. |
String description |
A description that tells the agent when and how to use the tool. |
Array<Parameter> parameters |
Describes the JSON parameters accepted by the tool. |
Functor<...> handler |
The internal callback invoked when the agent uses the tool. |
bool readOnly |
Indicates that the tool does not modify any data. |
ToolContext
Each time a tool handler is invoked, it receives a ToolContext object. ToolContext provides the following member functions:
StringView ToolContext::getWorkingDirectory() constReturns the agent's working directory.
String ToolContext::checkPathPermission(StringView path, bool withWriteAccess) constConverts
pathto an absolute path if read access is permitted, or write access ifwithWriteAccessis true. Otherwise returns an empty string.void ToolContext::appendResponse(Transcript::Message* toolCall, StringView text)Adds text to the tool response in a thread-safe manner. Can be called more than once to stream a response. Each call to
appendResponsecreates a newTranscript::Eventand buffers it in theAgentso that the application receives it as soon as possible. The complete tool response won't be sent to the remote inference server until the next turn.bool ToolContext::isCanceled() constReturns whether the agent has been canceled. Long-running tools should call this periodically and return promptly when it becomes true.
bool ToolContext::setCancelCallback(Functor<void()>&& callback)If the agent hasn't already been canceled, registers a cancellation callback and returns
true. Otherwise, if the agent was already canceled, clears any existing cancellation callback and returnsfalse. A tool that performs an interruptible blocking operation can usesetCancelCallback()to register a callback that unblocks it. The callback will be invoked from the client thread whenAgent::cancel()is called.void ToolContext::clearCancelCallback()Clears the registered cancellation callback. The callback must be cleared before the tool handler returns.
For example, a tool to count the number of bytes in a string can be implemented as follows.
void byteCountToolHandler(ToolContext* toolCtx, Transcript::Message* toolCall,
const json::Node& arguments) {
// Validate the argument.
const json::Node& textArg = arguments.get("text");
if (!textArg.isText()) {
toolCtx->appendResponse(toolCall, "Error: 'text' argument is required.");
return;
}
// Add response text to the transcript.
toolCtx->appendResponse(toolCall, String::format("{} bytes", textArg.text().numBytes()));
}
void addByteCountTool(Agent::Capabilities* capabilities) {
// Describe the tool and its arguments.
Owned<ToolDefinition> tool = Heap::create<ToolDefinition>();
tool->name = "byte_count";
tool->description = "Return the length of a string in bytes.";
ToolDefinition::Parameter& textParam = tool->parameters.append();
textParam.name = "text";
textParam.description = "Text to measure";
textParam.type = "string";
textParam.required = true;
tool->handler = byteCountToolHandler;
capabilities->tools.insertItem(std::move(tool));
}
Tool Monitoring
The shell tool is dangerous because it gives agents the ability to run arbitrary shell commands on the user's behalf. At the same time, it's a convenient way to give agents access to system applications, such as compilers and debuggers, so that they can automate large tasks end-to-end.
In Plywood, every shell request made by the agent is first passed to an application-defined callback, which must be specified when createShellTool is called:
Owned<ToolDefinition> createShellTool(Functor<bool(ToolContext* toolCtx, StringView shellCommand)>&& authorizer)
The authorizer callback lets the application decide whether each shell command is allowed to run. If the callback returns true, the shell command executes normally; if it returns false, permission is denied and an error is reported back to the agent.
The application responds to each tool request according to application-defined policies. If a shell command looks ambiguous, the application can even notify the user and await a response before returning from the callback. In addition, each tool request is automatically logged to the conversation transcript.
shell tool monitoring should be seen as the first line of defense against AI agents performing unwanted actions. In environments where stronger security guarantees are needed, the application should be sandboxed and monitored at the operating system level.
ShellAuthorizationPolicy
One way to implement the authorizer callback is to use a second agent to automatically review the requests made by the first agent. For convenience, Plywood provides a ShellAuthorizationPolicy helper class to support this case. It has the following public data members:
String policy |
A natural-language description of shell commands the agent is allowed to run. |
Agent::EndPoint authorizerEndPoint |
The authorizer's provider, protocol and model. |
Set<Owned<ToolDefinition>> authorizerTools |
The tools available to the authorizer. |
Functor<void(Agent*)> loggingHook |
Optional hook for logging the authorizer's transcript. Must consume events from the provided agent until the agent finishes. |
To run an authorizer agent, initialize a ShellAuthorizationPolicy object and call authorizeCommand().
bool ShellAuthorizationPolicy::authorizeCommand(ToolContext* toolCtx, StringView shellCommand) const
For an example of ShellAuthorizationPolicy being used in practice, see the agent sample.
ShellAuthorizationPolicy shellAuthorizationPolicy;
shellAuthorizationPolicy.policy = ...;
shellAuthorizationPolicy.authorizerEndPoint = ...;
shellAuthorizationPolicy.authorizerTools = ...;
Owned<ToolDefinition> shellTool = createShellTool(
[policy = std::move(shellAuthorizationPolicy)](ToolContext* toolCtx,
StringView shellCommand) {
return policy.authorizeCommand(toolCtx, shellCommand);
});