Skip to content

Commit 0fa6bf4

Browse files
WIP: write cli wrappers
1 parent 860c198 commit 0fa6bf4

File tree

20 files changed

+946
-0
lines changed

20 files changed

+946
-0
lines changed

agents/agents-cli/Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM node:20-slim
2+
3+
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
4+
RUN npm install -g @openai/codex @anthropic-ai/claude-code && npm cache clean --force
5+
6+
# Set a non-root user (optional but recommended)
7+
USER node
8+
9+
WORKDIR /workspace

agents/agents-cli/build.gradle.kts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import ai.koog.gradle.publish.maven.Publishing.publishToMaven
2+
3+
group = rootProject.group
4+
version = rootProject.version
5+
6+
plugins {
7+
id("ai.kotlin.multiplatform")
8+
}
9+
10+
kotlin {
11+
sourceSets {
12+
commonMain {
13+
dependencies {
14+
api(libs.kotlinx.serialization.json)
15+
api(libs.kotlinx.coroutines.core)
16+
implementation(libs.oshai.kotlin.logging)
17+
18+
api(project(":utils"))
19+
api(project(":agents:agents-core"))
20+
}
21+
}
22+
23+
commonTest {
24+
dependencies {
25+
implementation(libs.kotest.assertions.core)
26+
}
27+
}
28+
29+
jvmTest {
30+
dependencies {
31+
implementation(kotlin("test-junit5"))
32+
implementation(libs.junit.jupiter.params)
33+
implementation(libs.kotlinx.coroutines.test)
34+
}
35+
}
36+
}
37+
38+
explicitApi()
39+
}
40+
41+
publishToMaven()
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package ai.koog.agents.cli
2+
3+
import ai.koog.agents.cli.transport.CliAvailable
4+
import ai.koog.agents.cli.transport.CliUnavailable
5+
import ai.koog.agents.core.agent.AIAgent
6+
import ai.koog.agents.core.agent.AIAgentState
7+
import ai.koog.agents.core.agent.config.AIAgentConfig
8+
import ai.koog.agents.core.annotation.InternalAgentsApi
9+
import ai.koog.agents.core.dsl.builder.AIAgentNodeDelegate
10+
import io.github.oshai.kotlinlogging.KotlinLogging
11+
import kotlinx.coroutines.flow.onEach
12+
import kotlinx.coroutines.flow.toList
13+
import kotlinx.serialization.json.Json
14+
import kotlinx.serialization.json.JsonElement
15+
import kotlinx.serialization.json.JsonObject
16+
import kotlinx.serialization.json.JsonPrimitive
17+
import kotlinx.serialization.json.contentOrNull
18+
import kotlinx.serialization.json.jsonObject
19+
import java.util.UUID
20+
import kotlin.reflect.typeOf
21+
22+
/**
23+
* Base helper: runs a process and exposes stdout/stderr as a Flow of AgentEvents.
24+
*/
25+
// TODO(): support structured output
26+
public abstract class CliAIAgent<Result>(
27+
private val config: CliAIAgentConfig,
28+
private val name: String = config.binary
29+
) : AIAgent<String, Result?>() {
30+
31+
protected abstract val commandOptions: List<String>
32+
33+
/**
34+
* Builds the environment variables for the agent process.
35+
* For example, most agents may require an API key or other credentials.
36+
*/
37+
protected abstract fun buildEnvironment(): Map<String, String>
38+
39+
/**
40+
* Extracts the result of the agent run.
41+
*
42+
* This method processes a sequence of generated agent events and returns the agent execution result.
43+
*
44+
* @param events a list of events of type [AgentEvent], streamed by the agent cli
45+
* @return a [Result] object representing the extracted result, or null if no result received
46+
*/
47+
protected abstract fun extractResult(events: List<AgentEvent>): Result?
48+
49+
override val id: String = UUID.randomUUID().toString()
50+
51+
// TODO(): implement these overrides properly
52+
// note: it might require refactoring the config and the state logic
53+
54+
override val agentConfig: AIAgentConfig
55+
get() = throw UnsupportedOperationException()
56+
57+
override suspend fun getState(): AIAgentState<Result?> = throw UnsupportedOperationException()
58+
59+
override suspend fun close() {
60+
// No-op by default
61+
}
62+
63+
@OptIn(InternalAgentsApi::class)
64+
override suspend fun run(agentInput: String): Result? {
65+
connect()
66+
67+
logger.info { "Starting agent '$name' with binary '$config.binary' in workspace '${config.workspace}'" }
68+
val startTime = System.currentTimeMillis()
69+
70+
val processEvents = config.transport.execute(
71+
command = listOf(config.binary) + commandOptions + agentInput,
72+
workspace = config.workspace,
73+
env = buildEnvironment(),
74+
timeout = config.timeout
75+
).onEach {
76+
logEvent(it)
77+
}.toList()
78+
79+
val agentEvents = processEvents.filterIsInstance<AgentEvent>()
80+
81+
val result = extractResult(agentEvents)
82+
val durationMs = System.currentTimeMillis() - startTime
83+
84+
val exitCode = processEvents.filterIsInstance<CliAIAgentEvent.Exit>().firstOrNull()?.exitCode ?: -1
85+
86+
logger.info { "Agent '$name' finished in ${durationMs}ms with exit code $exitCode" }
87+
logger.info { "Agent '$name' result: $result" }
88+
89+
return result
90+
}
91+
92+
/**
93+
* Transforms this agent into a node that can be used in a graph strategy.
94+
*/
95+
public fun asNode(name: String? = null): AIAgentNodeDelegate<String, Result?> =
96+
AIAgentNodeDelegate<String, Result?>(
97+
name = name,
98+
inputType = typeOf<String>(),
99+
outputType = typeOf<Any?>(),
100+
execute = { input -> run(input) }
101+
)
102+
103+
private fun connect() {
104+
when (val availability = config.transport.checkAvailability(config.binary)) {
105+
is CliAvailable -> {
106+
logger.info { "Connected to agent '$name' (version: ${availability.version ?: "unknown"})" }
107+
}
108+
109+
is CliUnavailable -> {
110+
throw CliNotFoundException(
111+
"Agent '$name' CLI '$config.binary' is not available: ${availability.reason}",
112+
availability.cause
113+
)
114+
}
115+
}
116+
}
117+
118+
protected companion object {
119+
private val logger = KotlinLogging.logger { }
120+
121+
private fun logEvent(event: CliAIAgentEvent) {
122+
logger.info {
123+
when (event) {
124+
is CliAIAgentEvent.Started -> "Agent Started"
125+
is AgentEvent.Stdout -> "[STDOUT] ${event.content}"
126+
is AgentEvent.Stderr -> "[STDERR] ${event.content}"
127+
is CliAIAgentEvent.Exit -> "Agent Exited (code: ${event.exitCode})"
128+
is CliAIAgentEvent.Failed -> "Agent Failed: ${event.message}"
129+
}
130+
}
131+
}
132+
133+
// json utils
134+
135+
private val json: Json = Json { ignoreUnknownKeys = true }
136+
137+
/**
138+
* Converts a list of agent events to a list of JSON objects from stdout
139+
*/
140+
public fun toJsonStdoutEvents(events: List<AgentEvent>): List<JsonObject> =
141+
events
142+
.filterIsInstance<AgentEvent.Stdout>()
143+
.mapNotNull {
144+
runCatching {
145+
json.decodeFromString<JsonObject>(it.content).jsonObject
146+
}.getOrNull()
147+
}
148+
149+
/**
150+
* Converts a JSON primitive to a string
151+
*/
152+
public val JsonElement.stringVal: String?
153+
get() = (this as? JsonPrimitive)?.contentOrNull
154+
}
155+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package ai.koog.agents.cli
2+
3+
import ai.koog.agents.cli.transport.CliTransport
4+
import java.io.File
5+
import kotlin.time.Duration
6+
7+
/**
8+
* Base configuration for agent processes.
9+
*/
10+
public abstract class CliAIAgentConfig(
11+
public val binary: String,
12+
public val transport: CliTransport,
13+
public val workspace: File,
14+
public val timeout: Duration?
15+
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package ai.koog.agents.cli
2+
3+
/**
4+
* Represents an event of an agent process.
5+
*/
6+
public sealed interface CliAIAgentEvent {
7+
/**
8+
* An event indicating that the agent process has started.
9+
*/
10+
public object Started : CliAIAgentEvent
11+
12+
/**
13+
* An event indicating that the agent process has exited.
14+
*/
15+
public class Exit(public val exitCode: Int) : CliAIAgentEvent
16+
17+
/**
18+
* An event indicating that the agent process has failed.
19+
*/
20+
public class Failed(public val message: String?) : CliAIAgentEvent
21+
}
22+
23+
/**
24+
* Represents an event of an agent from an agent cli.
25+
*/
26+
public sealed interface AgentEvent : CliAIAgentEvent {
27+
/**
28+
* The content of the event.
29+
*/
30+
public val content: String
31+
32+
/**
33+
* An event streamed from stdout.
34+
*/
35+
public class Stdout(public override val content: String) : AgentEvent
36+
37+
/**
38+
* An event streamed from stderr.
39+
*/
40+
public class Stderr(public override val content: String) : AgentEvent
41+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package ai.koog.agents.cli
2+
3+
import kotlin.time.Duration
4+
5+
/**
6+
* Base class for cli-agent-related exceptions.
7+
*/
8+
public open class CliAgentException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
9+
10+
/**
11+
* Exception indicating that a CLI agent binary was not found.
12+
*/
13+
public class CliNotFoundException(message: String, cause: Throwable? = null) : CliAgentException(message, cause)
14+
15+
/**
16+
* Exception indicating that a CLI agent run timed out.
17+
*/
18+
public class CliAgentTimeoutException(message: String, public val timeout: Duration, cause: Throwable? = null) :
19+
CliAgentException(message, cause)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package ai.koog.agents.cli.claude
2+
3+
import ai.koog.agents.cli.AgentEvent
4+
import ai.koog.agents.cli.CliAIAgent
5+
6+
/**
7+
* Claude Code CLI wrapper.
8+
*/
9+
public class ClaudeCodeAgent(
10+
public val config: ClaudeCodeConfig = ClaudeCodeConfig(),
11+
) : CliAIAgent<String>(config) {
12+
13+
override val commandOptions: List<String> = buildList {
14+
add("-p")
15+
16+
add("--output-format")
17+
add("stream-json")
18+
19+
if (config.verbose) add("--verbose")
20+
21+
if (config.includePartialMessages) add("--include-partial-messages")
22+
23+
config.model?.let {
24+
add("--model")
25+
add(it)
26+
}
27+
}
28+
29+
override fun buildEnvironment(): Map<String, String> {
30+
return buildMap {
31+
config.apiKey?.let { put("ANTHROPIC_API_KEY", it) }
32+
}
33+
}
34+
35+
override fun extractResult(events: List<AgentEvent>): String? {
36+
val jsonEvents = toJsonStdoutEvents(events)
37+
38+
val resultJson = jsonEvents.lastOrNull { it["type"]?.stringVal == "result" }
39+
val result = resultJson?.let { it["result"]?.stringVal }
40+
41+
return result
42+
}
43+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package ai.koog.agents.cli.claude
2+
3+
import ai.koog.agents.cli.CliAIAgentConfig
4+
import ai.koog.agents.cli.transport.CliTransport
5+
import java.io.File
6+
import kotlin.time.Duration
7+
8+
/**
9+
* Claude Code config.
10+
*/
11+
public class ClaudeCodeConfig(
12+
binary: String = "claude",
13+
transport: CliTransport = CliTransport.Default,
14+
workspace: File = File("."),
15+
timeout: Duration? = null,
16+
public val model: String? = null,
17+
public val verbose: Boolean = true,
18+
public val includePartialMessages: Boolean = false,
19+
public val apiKey: String? = null,
20+
) : CliAIAgentConfig(binary, transport, workspace, timeout)

0 commit comments

Comments
 (0)