This guide provides detailed information for implementing and using the Agent Client Protocol (ACP) in Pharo. It covers architecture decisions, class design, usage patterns, and best practices specific to the Pharo environment.
The Pharo-ACP implementation follows these principles:
LLM-Pharo-ACP/
├── Core/
│ ├── ACPConnection
│ ├── ACPSession
│ ├── ACPMessage
│ ├── ACPRequest
│ ├── ACPResponse
│ ├── ACPNotification
│ └── ACPError
├── Client/
│ ├── ACPClient
│ ├── ACPClientCapabilities
│ └── ACPClientSession
├── Agent/
│ ├── ACPAgent
│ ├── ACPAgentCapabilities
│ └── ACPAgentSession
├── Messages/
│ ├── ACPInitializeRequest
│ ├── ACPInitializeResponse
│ ├── ACPNewSessionRequest
│ ├── ACPPromptRequest
│ └── ...
├── Content/
│ ├── ACPContentBlock
│ ├── ACPTextContent
│ ├── ACPImageContent
│ ├── ACPResourceContent
│ ├── ACPResourceLink
│ └── ACPDiffContent
├── ToolCalls/
│ ├── ACPToolCall
│ ├── ACPToolCallUpdate
│ └── ACPToolCallKind
├── Transport/
│ ├── ACPTransport
│ ├── ACPStdioTransport
│ ├── ACPHTTPTransport
│ └── ACPWebSocketTransport
├── Capabilities/
│ ├── ACPCapabilities
│ ├── ACPPromptCapabilities
│ └── ACPMCPCapabilities
└── Utilities/
├── ACPJSON
├── ACPValidator
└── ACPLogger
LLM-Pharo-ACP-Tests/
├── Core/
│ ├── ACPConnectionTest
│ ├── ACPSessionTest
│ └── ACPMessageTest
├── Client/
│ └── ACPClientTest
├── Agent/
│ └── ACPAgentTest
├── Messages/
│ └── ACPMessageTests
├── Content/
│ └── ACPContentBlockTests
├── Integration/
│ └── ACPIntegrationTests
└── Examples/
└── ACPExampleTests
Object
└── ACPMessage
├── ACPRequest
│ ├── ACPInitializeRequest
│ ├── ACPAuthenticateRequest
│ ├── ACPNewSessionRequest
│ ├── ACPLoadSessionRequest
│ ├── ACPPromptRequest
│ ├── ACPSetConfigOptionRequest
│ ├── ACPSetModeRequest
│ ├── ACPReadFileRequest
│ ├── ACPWriteFileRequest
│ ├── ACPCreateTerminalRequest
│ ├── ACPTerminalOutputRequest
│ ├── ACPWaitForExitRequest
│ ├── ACPTerminalKillRequest
│ ├── ACPTerminalReleaseRequest
│ └── ACPPermissionRequest
├── ACPResponse
│ ├── ACPInitializeResponse
│ ├── ACPNewSessionResponse
│ ├── ACPPromptResponse
│ ├── ACPReadFileResponse
│ ├── ACPCreateTerminalResponse
│ ├── ACPTerminalOutputResponse
│ ├── ACPWaitForExitResponse
│ └── ACPPermissionResponse
├── ACPNotification
│ ├── ACPSessionUpdate
│ └── ACPCancelRequest
└── ACPError
Object
└── ACPContentBlock
├── ACPTextContent
├── ACPImageContent
├── ACPAudioContent
├── ACPResourceContent
├── ACPResourceLink
├── ACPDiffContent
└── ACPTerminalContent
Object
└── ACPTransport
├── ACPStdioTransport
├── ACPHTTPTransport
└── ACPWebSocketTransport
Manages the JSON-RPC connection between client and agent.
Object subclass: #ACPConnection
instanceVariableNames: 'transport messageId handlers logger'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Core'
Key Methods:
ACPConnection>>initialize
"Initialize the connection"
super initialize.
messageId := 0.
handlers := Dictionary new.
logger := ACPLogger default
ACPConnection>>sendRequest: aRequest
"Send a request and return a promise for the response"
| id |
id := self nextMessageId.
aRequest id: id.
^ self sendMessage: aRequest
ACPConnection>>sendNotification: aNotification
"Send a notification (no response expected)"
^ self sendMessage: aNotification
ACPConnection>>handleIncoming: aMessage
"Handle incoming message from transport"
aMessage isResponse
ifTrue: [ self handleResponse: aMessage ]
ifFalse: [ self handleRequest: aMessage ]
ACPConnection>>nextMessageId
"Generate next message ID"
messageId := messageId + 1.
^ messageId
Represents a conversation session.
Object subclass: #ACPSession
instanceVariableNames: 'sessionId cwd mcpServers mode config history connection'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Core'
Key Methods:
ACPSession>>sendPrompt: aContentBlockCollection
"Send a prompt to the agent"
| request |
request := ACPPromptRequest new
sessionId: self sessionId;
prompt: aContentBlockCollection;
yourself.
^ connection sendRequest: request
ACPSession>>onUpdate: aBlock
"Register callback for session updates"
connection onNotification: 'session/update' do: [ :update |
(update sessionId = self sessionId)
ifTrue: [ aBlock value: update ] ]
ACPSession>>cancel
"Cancel current operation"
| notification |
notification := ACPCancelRequest new
sessionId: self sessionId;
yourself.
connection sendNotification: notification
ACPSession>>setMode: aModeId
"Change session mode"
| request |
request := ACPSetModeRequest new
sessionId: self sessionId;
modeId: aModeId;
yourself.
^ connection sendRequest: request
Client-side ACP implementation.
Object subclass: #ACPClient
instanceVariableNames: 'connection capabilities info sessions'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Client'
Key Methods:
ACPClient>>initialize: anAgentTransport
"Initialize connection with agent"
| request response |
connection := ACPConnection transport: anAgentTransport.
request := ACPInitializeRequest new
protocolVersion: 1;
clientInfo: self clientInfo;
clientCapabilities: self clientCapabilities;
yourself.
response := connection sendRequest: request.
self negotiateCapabilities: response.
^ response
ACPClient>>newSession: aWorkingDirectory
"Create new session"
| request response session |
request := ACPNewSessionRequest new
cwd: aWorkingDirectory asAbsolute pathString;
mcpServers: #();
yourself.
response := connection sendRequest: request.
session := ACPClientSession new
sessionId: response sessionId;
connection: connection;
cwd: aWorkingDirectory;
yourself.
sessions at: response sessionId put: session.
^ session
ACPClient>>readFile: aPath inSession: aSession
"Read file contents"
| request response |
request := ACPReadFileRequest new
sessionId: aSession sessionId;
path: aPath asAbsolute pathString;
yourself.
response := connection sendRequest: request.
^ response content
ACPClient>>writeFile: aPath content: aString inSession: aSession
"Write file contents"
| request |
request := ACPWriteFileRequest new
sessionId: aSession sessionId;
path: aPath asAbsolute pathString;
content: aString;
yourself.
^ connection sendRequest: request
Agent-side ACP implementation.
Object subclass: #ACPAgent
instanceVariableNames: 'connection capabilities info sessions'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Agent'
Key Methods:
ACPAgent>>start
"Start the agent and listen for requests"
connection := ACPConnection transport: ACPStdioTransport new.
self registerHandlers.
connection start
ACPAgent>>handleInitialize: aRequest
"Handle initialize request"
| response |
response := ACPInitializeResponse new
protocolVersion: 1;
agentInfo: self agentInfo;
agentCapabilities: self agentCapabilities;
authenticationMethods: #();
yourself.
^ response
ACPAgent>>handleNewSession: aRequest
"Handle session creation"
| session response |
session := ACPAgentSession new
sessionId: self generateSessionId;
cwd: aRequest cwd asFileReference;
connection: connection;
yourself.
sessions at: session sessionId put: session.
response := ACPNewSessionResponse new
sessionId: session sessionId;
yourself.
^ response
ACPAgent>>handlePrompt: aRequest
"Handle prompt request"
| session |
session := sessions at: aRequest sessionId.
^ session processPrompt: aRequest prompt
ACPAgent>>sendUpdate: anUpdate toSession: aSession
"Send session update notification"
| notification |
notification := ACPSessionUpdate new
sessionId: aSession sessionId;
update: anUpdate;
yourself.
connection sendNotification: notification
"Create client and connect to agent"
| client agent session response |
"Create agent transport (stdio)"
agent := ACPStdioTransport command: '/path/to/agent'.
"Initialize client"
client := ACPClient new.
client initialize: agent.
"Create session"
session := client newSession: '/home/user/project' asFileReference.
"Send prompt"
response := session sendPrompt: {
ACPTextContent new text: 'Create a HelloWorld class'.
}.
"Handle updates"
session onUpdate: [ :update |
update type = 'message' ifTrue: [
Transcript show: update content; cr.
].
update type = 'tool_call' ifTrue: [
Transcript show: 'Tool: ', update title; cr.
].
].
"Wait for response"
response wait.
Transcript show: 'Done: ', response stopReason; cr.
"Create and start agent"
| agent |
agent := ACPAgent new
agentInfo: (ACPAgentInfo new
name: 'Pharo Assistant';
version: '1.0.0';
yourself);
agentCapabilities: (ACPAgentCapabilities new
loadSession: false;
promptCapabilities: (ACPPromptCapabilities new
image: false;
audio: false;
embeddedContext: true;
yourself);
yourself);
yourself.
"Register prompt handler"
agent onPrompt: [ :session :prompt |
| text |
text := prompt first text.
"Send thinking update"
agent sendUpdate: (ACPMessageUpdate new
role: 'agent';
content: { ACPTextContent new text: 'Processing request...' };
yourself) toSession: session.
"Process request"
text = 'hello' ifTrue: [
agent sendUpdate: (ACPMessageUpdate new
role: 'agent';
content: { ACPTextContent new text: 'Hello! How can I help?' };
yourself) toSession: session.
].
"Return response"
ACPPromptResponse new stopReason: 'end_turn'; yourself
].
"Start agent"
agent start.
"Text content"
textContent := ACPTextContent new
text: 'This is a message';
yourself.
"Image content"
imageContent := ACPImageContent new
mimeType: 'image/png';
data: imageData base64Encoded;
uri: 'file:///path/to/image.png';
yourself.
"Embedded resource"
resourceContent := ACPResourceContent new
resource: (ACPResource new
uri: 'file:///path/to/file.js';
text: fileContents;
mimeType: 'text/javascript';
yourself);
yourself.
"Resource link"
resourceLink := ACPResourceLink new
uri: 'file:///path/to/document.pdf';
name: 'Documentation';
mimeType: 'application/pdf';
size: 1024000;
yourself.
"Diff content"
diffContent := ACPDiffContent new
path: '/path/to/file.js';
oldText: 'const x = 1;';
newText: 'const x = 2;';
mimeType: 'text/javascript';
yourself.
"Create tool call"
toolCall := ACPToolCall new
toolCallId: 'tool_', UUID new asString;
title: 'Read config.json';
kind: #read;
status: #pending;
location: (ACPLocation new
path: '/absolute/path/to/config.json';
yourself);
content: #();
yourself.
"Send tool call update"
agent sendUpdate: toolCall toSession: session.
"Update tool status"
toolCallUpdate := ACPToolCallUpdate new
toolCallId: toolCall toolCallId;
status: #completed;
content: { ACPTextContent new text: 'File read successfully' };
yourself.
agent sendUpdate: toolCallUpdate toSession: session.
"Request permission"
permissionRequest := ACPPermissionRequest new
sessionId: session sessionId;
title: 'Delete test files?';
description: 'Remove 5 temporary test files';
options: {
ACPPermissionOption new
optionId: 'allow';
kind: #allow_once;
label: 'Delete files';
yourself.
ACPPermissionOption new
optionId: 'reject';
kind: #reject_once;
label: 'Keep files';
yourself.
};
yourself.
"Send request and wait for response"
permissionResponse := connection sendRequest: permissionRequest.
permissionResponse selectedOptionId = 'allow' ifTrue: [
"Proceed with deletion"
].
"Read file"
content := client readFile: '/path/to/config.json' asFileReference
inSession: session.
"Read partial file"
request := ACPReadFileRequest new
sessionId: session sessionId;
path: '/path/to/large-file.txt';
startLine: 100;
lineLimit: 50;
yourself.
content := connection sendRequest: request.
"Write file"
client writeFile: '/path/to/output.txt' asFileReference
content: 'Hello from Pharo-ACP!'
inSession: session.
"Create terminal and run command"
createRequest := ACPCreateTerminalRequest new
sessionId: session sessionId;
command: 'echo';
args: #('Hello Terminal');
cwd: session cwd pathString;
yourself.
createResponse := connection sendRequest: createRequest.
terminalId := createResponse terminalId.
"Wait for completion"
waitRequest := ACPWaitForExitRequest new
sessionId: session sessionId;
terminalId: terminalId;
yourself.
waitResponse := connection sendRequest: waitRequest.
Transcript show: 'Output: ', waitResponse output; cr.
Transcript show: 'Exit code: ', waitResponse exitCode asString; cr.
"Release terminal"
releaseRequest := ACPTerminalReleaseRequest new
sessionId: session sessionId;
terminalId: terminalId;
yourself.
connection sendRequest: releaseRequest.
Test individual components in isolation:
TestCase subclass: #ACPMessageTest
instanceVariableNames: ''
classVariableNames: ''
package: 'LLM-Pharo-ACP-Tests-Core'
ACPMessageTest>>testInitializeRequestSerialization
| request json |
request := ACPInitializeRequest new
protocolVersion: 1;
clientInfo: (ACPClientInfo new name: 'Test'; yourself);
yourself.
json := request asJSON.
self assert: (json at: 'jsonrpc') equals: '2.0'.
self assert: (json at: 'method') equals: 'initialize'.
self assert: ((json at: 'params') at: 'protocolVersion') equals: 1.
ACPMessageTest>>testInitializeResponseDeserialization
| json response |
json := '{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": 1,
"agentInfo": {"name": "Test Agent"},
"agentCapabilities": {},
"authenticationMethods": []
}
}'.
response := ACPMessage fromJSON: json.
self assert: response class equals: ACPInitializeResponse.
self assert: response protocolVersion equals: 1.
self assert: response agentInfo name equals: 'Test Agent'.
Test complete workflows:
TestCase subclass: #ACPIntegrationTest
instanceVariableNames: 'client agent session'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Tests-Integration'
ACPIntegrationTest>>setUp
super setUp.
agent := self createMockAgent.
client := ACPClient new.
client initialize: agent transport.
session := client newSession: FileSystem workingDirectory.
ACPIntegrationTest>>testCompletePromptFlow
| response updates |
updates := OrderedCollection new.
session onUpdate: [ :update | updates add: update ].
response := session sendPrompt: {
ACPTextContent new text: 'test prompt'
}.
self assert: updates notEmpty.
self assert: response stopReason equals: 'end_turn'.
ACPIntegrationTest>>testFileOperations
| content |
"Write file"
client writeFile: self testFile
content: 'test content'
inSession: session.
"Read file back"
content := client readFile: self testFile inSession: session.
self assert: content equals: 'test content'.
Executable examples that serve as documentation:
TestCase subclass: #ACPExampleTest
instanceVariableNames: ''
classVariableNames: ''
package: 'LLM-Pharo-ACP-Tests-Examples'
ACPExampleTest>>exampleBasicClientUsage
"Demonstrates basic client usage"
| client session response |
client := ACPClient new.
client initialize: self mockAgent.
session := client newSession: FileSystem workingDirectory.
response := session sendPrompt: {
ACPTextContent new text: 'Hello'
}.
self assert: response isKindOf: ACPPromptResponse.
"Always handle errors gracefully"
[
response := connection sendRequest: request.
] on: ACPError do: [ :error |
error code = -32003 ifTrue: [
"Handle resource not found"
Transcript show: 'Resource not found: ', error message; cr.
] ifFalse: [
"Handle other errors"
error signal.
]
].
"Validate all incoming messages"
ACPMessage>>validate
self protocolVersion = 1 ifFalse: [
ACPValidationError signal: 'Invalid protocol version'
].
self method ifNil: [
ACPValidationError signal: 'Missing method'
].
"Use structured logging"
connection logger
info: 'Sending request'
context: {
'method' -> request method.
'id' -> request id.
'sessionId' -> request sessionId
} asDictionary.
"Always release resources"
[
terminal := client createTerminal: command inSession: session.
output := terminal waitForExit.
] ensure: [
terminal release.
].
"Always check capabilities before use"
client capabilities fs readTextFile ifTrue: [
content := client readFile: path inSession: session.
] ifFalse: [
self error: 'File reading not supported by client'.
].
"Integrate with Pharo's UI"
ACPBrowser>>initialize
super initialize.
self initializeAgent.
self setupUI.
ACPBrowser>>setupUI
"Create browser UI with agent chat"
self buildChatPanel.
self buildCodePanel.
self buildToolCallPanel.
ACPBrowser>>sendPromptFromSelection
| selection prompt |
selection := self selectedText.
prompt := {
ACPTextContent new text: 'Explain this code:'.
ACPResourceContent new
resource: (ACPResource new
uri: 'selection://current';
text: selection;
yourself);
yourself
}.
session sendPrompt: prompt.
"Use Pharo's file system abstraction"
ACPClient>>readFile: aFileReference inSession: aSession
| request |
request := ACPReadFileRequest new
sessionId: aSession sessionId;
path: aFileReference fullName;
yourself.
^ connection sendRequest: request
ACPClient>>writeFile: aFileReference content: aString inSession: aSession
| request |
request := ACPWriteFileRequest new
sessionId: aSession sessionId;
path: aFileReference fullName;
content: aString;
yourself.
^ connection sendRequest: request
"Integrate with Pharo's process model"
ACPStdioTransport>>start
process := PipeableOSProcess command: command arguments: args.
process errorPipelineContents ifNotNil: [ :error |
self logger error: 'Agent error: ', error.
].
self startReadLoop.
ACPStdioTransport>>startReadLoop
[
[ process isRunning ] whileTrue: [
line := process outputPipelineContents.
line ifNotNil: [ self handleLine: line ].
]
] forkNamed: 'ACP Transport Reader'.
"Batch multiple updates for efficiency"
ACPSession>>sendBatchUpdate: aCollection
aCollection do: [ :update |
self sendUpdate: update
] separatedBy: [
1 milliSeconds wait "Small delay for batching"
].
"Cache file contents to reduce reads"
ACPClient>>readFileCached: aPath inSession: aSession
^ fileCache at: aPath ifAbsentPut: [
self readFile: aPath inSession: aSession
]
"Use promises for async operations"
ACPConnection>>sendRequest: aRequest
| promise |
promise := Promise new.
handlers at: aRequest id put: promise.
self sendMessage: aRequest.
^ promise
"Define custom method with underscore prefix"
ACPClient>>registerMethod: methodName handler: aBlock
connection registerHandler: methodName do: aBlock.
"Example: Custom code analysis method"
client registerMethod: '_custom/analyze_code' handler: [ :request |
"Implement custom analysis"
ACPResponse new result: analysisResult
].
"Define custom content block type"
ACPContentBlock subclass: #ACPCustomContent
instanceVariableNames: 'customField'
classVariableNames: ''
package: 'LLM-Pharo-ACP-Extensions'
ACPCustomContent>>asJSON
^ super asJSON
at: 'type' put: 'custom';
at: 'customField' put: customField;
yourself
"Add custom metadata using _meta field"
ACPMessage>>addMetadata: aKey value: aValue
self metadata ifNil: [ metadata := Dictionary new ].
metadata at: ('_', aKey) put: aValue.
"Example usage"
request addMetadata: 'priority' value: 'high'.
request addMetadata: 'source' value: 'user_action'.
"Define baseline for Metacello"
baseline: spec
<baseline>
spec for: #common do: [
"Dependencies"
spec
baseline: 'NeoJSON'
with: [ spec repository: 'github://svenvc/NeoJSON/repository' ].
"Packages"
spec
package: 'LLM-Pharo-ACP' with: [ spec requires: #('NeoJSON') ];
package: 'LLM-Pharo-ACP-Tests' with: [ spec requires: #('LLM-Pharo-ACP') ].
"Groups"
spec
group: 'Core' with: #('LLM-Pharo-ACP');
group: 'Tests' with: #('LLM-Pharo-ACP-Tests');
group: 'default' with: #('Core' 'Tests').
]
"Load in Pharo image"
Metacello new
githubUser: 'pharo-llm'
project: 'pharo-acp'
commitish: 'main'
path: 'src';
baseline: 'LLMPharoACP';
load.
Issue: Connection timeout
"Solution: Increase timeout"
connection timeout: 30 seconds.
Issue: Invalid JSON parsing
"Solution: Enable debug logging"
connection logger level: #debug.
Issue: Process not found
"Solution: Check agent path and permissions"
agent := ACPStdioTransport command: '/full/path/to/agent'.
agent verifyExecutable ifFalse: [
self error: 'Agent not found or not executable'
].
"Enable protocol logging"
ACPLogger default
level: #debug;
output: Transcript.
"Inspect messages"
connection onMessage: [ :msg |
msg inspect.
].
"Break on errors"
ACPError signal inspect.
ACP-PROTOCOL.mdAPI-REFERENCE.mdexamples/ directoryLLM-Pharo-ACP-Tests package