Add koog module: a tool-calling agent, a strategy graph with a Java node and the Spring Boot starter on Koog 1.3.0, tested against a scripted OpenAI-compatible endpoint
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b6FHj1ptP5Fej2eFDo
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# koog
|
||||
|
||||
Companion code for *Koog: JetBrains' Kotlin AI Agent Framework on the JVM, Your First Agent with Tools in Java*
|
||||
on [ankurm.com](https://ankurm.com). One agent with two tools, the same agent as an explicit strategy graph with a
|
||||
plain-Java node in front of the model, and the Spring Boot starter around it.
|
||||
|
||||
There is no OpenAI key here. `ScriptedOpenAIServer` is a local HTTP server that speaks the chat-completions
|
||||
format and answers from a script, and Koog's real `OpenAILLMClient` is pointed at it. The agent loop, the tool
|
||||
definitions Koog derives, the argument parsing and the HTTP calls all run for real; the model is the one scripted
|
||||
part. What a real model would decide is not tested here.
|
||||
|
||||
```bash
|
||||
mvn test -pl koog # rewrites output/01..06
|
||||
koog/scripts/kotlin-bom-trap.sh # rewrites output/07 (run from the repository root, after mvn test)
|
||||
```
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ServiceTools` | Two tools on a `ToolSet`: `@Tool`, `@LLMDescription`, `customName` |
|
||||
| `OpsAgents` | The first agent, the redacting graph strategy, the Mermaid diagram |
|
||||
| `OpsService`, `OpsController`, `OpsApplication` | Spring Boot: the starter's `openAIExecutor` bean and an agent built per request |
|
||||
| `ScriptedOpenAIServer`, `TestExecutors` | The stand-in model endpoint and an executor pointed at it |
|
||||
| `FirstAgentTest` | `output/01`: what Koog sends, request by request |
|
||||
| `StrategyGraphTest` | `output/02`: the graph, and a secret that never reaches the model |
|
||||
| `FailureModesTest` | `output/03`, `output/04`: tool errors, bad arguments, unknown tools, the iteration cap, running an agent twice |
|
||||
| `SpringBootStarterTest` | `output/05`, `output/06`: the beans the starter registers, and startup with no API key |
|
||||
| `scripts/kotlin-bom-trap.sh` | `output/07`: the `NoClassDefFoundError` when Spring Boot's BOM keeps Kotlin on 1.9.25 |
|
||||
|
||||
The `pom.xml` imports the Kotlin BOMs **before** the Spring Boot BOM on purpose; the comment there explains why.
|
||||
|
||||
Verified against `ai.koog:koog-agents-jvm` **1.3.0** and `ai.koog:koog-spring-boot-starter` **1.3.0-beta** (beta),
|
||||
Spring Boot **3.5.16**, Kotlin **2.3.10**, JDK 25, JUnit 6.1.3.
|
||||
@@ -0,0 +1,18 @@
|
||||
agent.run("Is checkout healthy?") -> checkout is DEGRADED
|
||||
|
||||
HTTP calls the model endpoint received: 2
|
||||
POST /v1/chat/completions
|
||||
POST /v1/chat/completions
|
||||
|
||||
Request 1 - messages:
|
||||
system: You are an on-call assistant. Use the tools to answer; never guess a status.
|
||||
user: Is checkout healthy?
|
||||
Request 1 - tools Koog derived from ServiceTools:
|
||||
{"name":"getServiceStatus","description":"Returns the current status of one service: HEALTHY, DEGRADED or DOWN","parameters":{"type":"object","properties":{"service":{"description":"Service name, for example checkout","type":"string"}},"required":["service"]}}
|
||||
{"name":"error_rate","description":"Returns the percentage of failed requests for one service over the last N minutes","parameters":{"type":"object","properties":{"service":{"description":"Service name, for example checkout","type":"string"},"minutes":{"description":"Window in minutes","type":"integer"}},"required":["service","minutes"]}}
|
||||
|
||||
Request 2 - messages (the tool call and its result are now in the history):
|
||||
{"role":"system","content":"You are an on-call assistant. Use the tools to answer; never guess a status."}
|
||||
{"role":"user","content":"Is checkout healthy?"}
|
||||
{"role":"assistant","tool_calls":[{"id":"call_1","function":{"name":"getServiceStatus","arguments":"{\"service\":\"checkout\"}"},"type":"function"}]}
|
||||
{"role":"tool","content":"\"DEGRADED\"","tool_call_id":"call_1"}
|
||||
@@ -0,0 +1,23 @@
|
||||
Graph, as MermaidDiagramGenerator prints it:
|
||||
|
||||
---
|
||||
title: redacting_single_run
|
||||
---
|
||||
stateDiagram
|
||||
state "redact" as redact
|
||||
state "callLLM" as callLLM
|
||||
state "executeTool" as executeTool
|
||||
state "sendToolResult" as sendToolResult
|
||||
|
||||
[*] --> redact
|
||||
redact --> callLLM
|
||||
callLLM --> executeTool : transformed
|
||||
callLLM --> [*] : transformed
|
||||
executeTool --> sendToolResult
|
||||
sendToolResult --> executeTool : transformed
|
||||
sendToolResult --> [*] : transformed
|
||||
|
||||
The user typed: My key is sk-ABCDEFGH12345678. Is checkout healthy?
|
||||
The model got: My key is [REDACTED]. Is checkout healthy?
|
||||
Answer: checkout is DEGRADED
|
||||
Secret present in any request body: false
|
||||
@@ -0,0 +1,17 @@
|
||||
1. The tool throws IllegalArgumentException("Unknown service: nope")
|
||||
agent.run returned: There is no such service.
|
||||
LLM calls: 2
|
||||
what the model was told: tool: Tool with name 'getServiceStatus' failed to execute due to the error: Unknown service: nope!
|
||||
|
||||
2. The model sends {"minutes":"soon"} for an int parameter
|
||||
what the model was told: Tool with name 'error_rate' failed to parse arguments due to the error: Failed to parse literal '"soon"' as an int value at element: $.primitive | JSON input: "soon"
|
||||
|
||||
3. The model calls restartService, which is not registered
|
||||
the history sent back, last two messages:
|
||||
{"role":"assistant","content":"{\"tool_call_id\":\"c1\",\"tool_name\":\"restartService\",\"tool_args\":{\"service\":\"checkout\"}}"}
|
||||
{"role":"user","content":"{\"tool_call_id\":\"c1\",\"tool_name\":\"restartService\",\"tool_result\":\"Tool with name 'restartService' not found in the tool registry. Use one of the available tools.\"}"}
|
||||
|
||||
4. The model answers every request with another tool call, maxIterations(6)
|
||||
AIAgentMaxNumberOfIterationsReachedException
|
||||
LLM calls made before it stopped: 3
|
||||
default maxIterations of an agent you do not configure: 50
|
||||
@@ -0,0 +1,4 @@
|
||||
run 1: first
|
||||
run 2: second
|
||||
messages sent on run 2: 1
|
||||
{"role":"user","content":"hello again"}
|
||||
@@ -0,0 +1,11 @@
|
||||
PromptExecutor beans the starter registered (only OpenAI is configured):
|
||||
multiLLMPromptExecutor
|
||||
openAIExecutor
|
||||
|
||||
GET /ask?q=How bad are checkout errors?
|
||||
-> checkout fails 4.2% of requests over 15 minutes
|
||||
Model endpoint saw: [POST /v1/chat/completions, POST /v1/chat/completions]
|
||||
Tool argument the model sent: minutes=15 (an int, parsed by Koog)
|
||||
|
||||
context.getBean(PromptExecutor.class) with one provider configured:
|
||||
NoUniqueBeanDefinitionException: expected single matching bean but found 2: multiLLMPromptExecutor,openAIExecutor
|
||||
@@ -0,0 +1,4 @@
|
||||
ai.koog.openai.api-key is empty, ai.koog.openai.enabled=true is set:
|
||||
startup failed: true
|
||||
cause: NoSuchBeanDefinitionException
|
||||
message: No qualifying bean of type 'ai.koog.prompt.executor.model.PromptExecutor' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Qualifier("openAIExecutor")}
|
||||
@@ -0,0 +1,11 @@
|
||||
kotlin-stdlib on the classpath (correct, Kotlin BOM imported first):
|
||||
kotlin-stdlib-2.3.10.jar
|
||||
|
||||
Same classpath with kotlin-stdlib 1.9.25, which is what the Spring Boot 3.5.16 BOM picks when it comes first:
|
||||
kotlin-stdlib-1.9.25.jar
|
||||
|
||||
$ java -cp <that classpath> com.ankurm.koog.TrapMain
|
||||
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/time/Instant
|
||||
at ai.koog.utils.time.KoogClock.<clinit>(KoogClock.kt:27)
|
||||
at ai.koog.prompt.executor.model.PromptExecutorBuilder.openAI$default(PromptExecutorBuilder.kt:85)
|
||||
at ai.koog.prompt.executor.model.PromptExecutorBuilder.openAI(PromptExecutorBuilder.kt)
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm.agents</groupId>
|
||||
<artifactId>java-ai-agents</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>koog</artifactId>
|
||||
<name>Koog: an agent with tools, a strategy graph and Spring Boot</name>
|
||||
|
||||
<properties>
|
||||
<!-- Verified on Maven Central 2026-11-11 -->
|
||||
<koog.version>1.3.0</koog.version>
|
||||
<koog.beta.version>1.3.0-beta</koog.beta.version>
|
||||
<!-- koog-spring-boot-starter 1.3.0-beta is built against Spring Boot 3.5.14; the other modules use 4.x -->
|
||||
<koog.spring-boot.version>3.5.16</koog.spring-boot.version>
|
||||
<kotlinx-serialization.version>1.10.0</kotlinx-serialization.version>
|
||||
<kotlinx-coroutines.version>1.10.2</kotlinx-coroutines.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Order matters. Maven takes the FIRST version it meets, and the Spring Boot 3.5.16 BOM pins
|
||||
Kotlin 1.9.25, coroutines 1.8.1 and serialization 1.6.3. A <kotlin.version> property in this pom
|
||||
does not reach an imported BOM (only spring-boot-starter-parent honours it), so the Kotlin BOMs
|
||||
Koog needs are imported first. -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-bom</artifactId>
|
||||
<version>2.3.10</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-coroutines-bom</artifactId>
|
||||
<version>${kotlinx-coroutines.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-serialization-bom</artifactId>
|
||||
<version>${kotlinx-serialization.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${koog.spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>ai.koog</groupId>
|
||||
<artifactId>koog-agents-jvm</artifactId>
|
||||
<version>${koog.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ai.koog</groupId>
|
||||
<artifactId>koog-spring-boot-starter</artifactId>
|
||||
<version>${koog.beta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproduces the failure you get when Spring Boot's BOM wins and Kotlin stays on 1.9.25.
|
||||
# Run from the repository root after `mvn test -pl koog` (needs koog/target/test-classes).
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=output/07-kotlin-bom-trap.txt
|
||||
mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt >/dev/null 2>&1
|
||||
mvn -B -q dependency:get -Dartifact=org.jetbrains.kotlin:kotlin-stdlib:1.9.25 >/dev/null 2>&1
|
||||
OLD="$HOME/.m2/repository/org/jetbrains/kotlin/kotlin-stdlib/1.9.25/kotlin-stdlib-1.9.25.jar"
|
||||
|
||||
good="$(cat target/cp.txt)"
|
||||
bad="$(echo "$good" | tr ':' '\n' | sed "s#.*/kotlin-stdlib-2\.3\.10\.jar#$OLD#" | tr '\n' ':')"
|
||||
|
||||
{
|
||||
echo "kotlin-stdlib on the classpath (correct, Kotlin BOM imported first):"
|
||||
echo "$good" | tr ':' '\n' | grep -o 'kotlin-stdlib-[0-9.]*\.jar' | sed 's/^/ /'
|
||||
echo
|
||||
echo "Same classpath with kotlin-stdlib 1.9.25, which is what the Spring Boot 3.5.16 BOM picks when it comes first:"
|
||||
echo "$bad" | tr ':' '\n' | grep -o 'kotlin-stdlib-[0-9.]*\.jar' | sed 's/^/ /'
|
||||
echo
|
||||
echo '$ java -cp <that classpath> com.ankurm.koog.TrapMain'
|
||||
java -cp "target/test-classes:target/classes:$bad" com.ankurm.koog.TrapMain 2>&1 \
|
||||
| grep -vE 'SLF4J|Picked up|kotlin-logging' | head -4 | sed 's/^/ /'
|
||||
} > "$OUT"
|
||||
cat "$OUT"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in output/. Needs JDK 25 on JAVA_HOME and a network for the first Maven run.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B -q test
|
||||
scripts/kotlin-bom-trap.sh
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent;
|
||||
import ai.koog.agents.core.agent.MermaidDiagramGenerator;
|
||||
import ai.koog.agents.core.agent.entity.AIAgentEdge;
|
||||
import ai.koog.agents.core.agent.entity.AIAgentGraphStrategy;
|
||||
import ai.koog.agents.core.agent.entity.AIAgentNode;
|
||||
import ai.koog.agents.core.tools.ToolRegistry;
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels;
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
|
||||
/** Builds the agents used by the tests and by the Spring Boot service. */
|
||||
public final class OpsAgents {
|
||||
|
||||
public static final String SYSTEM_PROMPT =
|
||||
"You are an on-call assistant. Use the tools to answer; never guess a status.";
|
||||
|
||||
private OpsAgents() {}
|
||||
|
||||
/** The smallest useful agent: an executor, a model, a system prompt and a tool registry. */
|
||||
public static AIAgent<String, String> firstAgent(PromptExecutor executor) {
|
||||
return AIAgent.builder()
|
||||
.promptExecutor(executor)
|
||||
.llmModel(OpenAIModels.Chat.GPT4o)
|
||||
.systemPrompt(SYSTEM_PROMPT)
|
||||
.toolRegistry(ToolRegistry.builder().tools(new ServiceTools()).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** The same agent with one extra, ordinary Java node in front of the model: a redactor. */
|
||||
public static AIAgentGraphStrategy<String, String> redactingStrategy() {
|
||||
var graph = AIAgentGraphStrategy.builder("redacting_single_run")
|
||||
.withInput(String.class)
|
||||
.withOutput(String.class);
|
||||
|
||||
// A node is a function of (input, context). This one never talks to the model.
|
||||
var redact = AIAgentNode.builder("redact")
|
||||
.withInput(String.class)
|
||||
.withOutput(String.class)
|
||||
.withAction((input, ctx) -> input.replaceAll("sk-[A-Za-z0-9]{8,}", "[REDACTED]"))
|
||||
.build();
|
||||
|
||||
var callLLM = AIAgentNode.llmRequest("callLLM");
|
||||
var executeTool = AIAgentNode.executeTools("executeTool");
|
||||
var sendToolResult = AIAgentNode.llmSendToolResults("sendToolResult");
|
||||
|
||||
graph.edge(graph.nodeStart, redact);
|
||||
graph.edge(redact, callLLM);
|
||||
graph.edge(AIAgentEdge.builder().from(callLLM).to(executeTool).onToolCalls().build());
|
||||
graph.edge(AIAgentEdge.builder().from(callLLM).to(graph.nodeFinish).onTextMessage().build());
|
||||
graph.edge(executeTool, sendToolResult);
|
||||
graph.edge(AIAgentEdge.builder().from(sendToolResult).to(executeTool).onToolCalls().build());
|
||||
graph.edge(AIAgentEdge.builder().from(sendToolResult).to(graph.nodeFinish).onTextMessage().build());
|
||||
return graph.build();
|
||||
}
|
||||
|
||||
public static AIAgent<String, String> redactingAgent(PromptExecutor executor) {
|
||||
return AIAgent.builder()
|
||||
.graphStrategy(redactingStrategy())
|
||||
.promptExecutor(executor)
|
||||
.llmModel(OpenAIModels.Chat.GPT4o)
|
||||
.systemPrompt(SYSTEM_PROMPT)
|
||||
.toolRegistry(ToolRegistry.builder().tools(new ServiceTools()).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static String diagram() {
|
||||
return MermaidDiagramGenerator.INSTANCE.generate(redactingStrategy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class OpsApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OpsApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class OpsController {
|
||||
|
||||
private final OpsService service;
|
||||
|
||||
public OpsController(OpsService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/ask")
|
||||
public String ask(@RequestParam("q") String question) {
|
||||
return service.ask(question);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent;
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The starter gives us a PromptExecutor bean per configured provider. It does not give us an agent:
|
||||
* we build one per request around the shared executor, so no run shares state with another caller.
|
||||
*/
|
||||
@Service
|
||||
public class OpsService {
|
||||
|
||||
private final PromptExecutor executor;
|
||||
|
||||
public OpsService(@Qualifier("openAIExecutor") PromptExecutor executor) {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
public String ask(String question) {
|
||||
AIAgent<String, String> agent = OpsAgents.firstAgent(executor);
|
||||
return agent.run(question);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.agents.core.tools.annotations.LLMDescription;
|
||||
import ai.koog.agents.core.tools.annotations.Tool;
|
||||
import ai.koog.agents.core.tools.reflect.ToolSet;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Two read-only tools over a hard-coded service inventory. The model never sees this class:
|
||||
* Koog reads the annotations and the method signatures and turns them into tool definitions.
|
||||
*/
|
||||
@LLMDescription("Read-only questions about the health of our services")
|
||||
public class ServiceTools implements ToolSet {
|
||||
|
||||
private static final Map<String, String> STATUS = Map.of(
|
||||
"checkout", "DEGRADED",
|
||||
"search", "HEALTHY",
|
||||
"billing", "HEALTHY");
|
||||
|
||||
private static final Map<String, Double> ERROR_RATE = Map.of(
|
||||
"checkout", 4.2,
|
||||
"search", 0.1,
|
||||
"billing", 0.3);
|
||||
|
||||
@Tool
|
||||
@LLMDescription("Returns the current status of one service: HEALTHY, DEGRADED or DOWN")
|
||||
public String getServiceStatus(
|
||||
@LLMDescription("Service name, for example checkout") String service) {
|
||||
String status = STATUS.get(service);
|
||||
if (status == null) {
|
||||
throw new IllegalArgumentException("Unknown service: " + service);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
@Tool(customName = "error_rate")
|
||||
@LLMDescription("Returns the percentage of failed requests for one service over the last N minutes")
|
||||
public double getErrorRate(
|
||||
@LLMDescription("Service name, for example checkout") String service,
|
||||
@LLMDescription("Window in minutes") int minutes) {
|
||||
Double rate = ERROR_RATE.get(service);
|
||||
if (rate == null) {
|
||||
throw new IllegalArgumentException("Unknown service: " + service);
|
||||
}
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# The starter reads ai.koog.<provider>.*; setting OPENAI_API_KEY in the environment also activates it.
|
||||
ai.koog.openai.api-key=${OPENAI_API_KEY:}
|
||||
# Point the OpenAI client somewhere else (a proxy, a gateway, or the scripted server in the tests).
|
||||
# ai.koog.openai.base-url=https://api.openai.com
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent;
|
||||
import ai.koog.agents.core.agent.exception.AIAgentMaxNumberOfIterationsReachedException;
|
||||
import ai.koog.agents.core.tools.ToolRegistry;
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIModels;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/** What the agent does when the model, or a tool, misbehaves. Every case is a scripted model reply. */
|
||||
class FailureModesTest {
|
||||
|
||||
private static JsonNode lastMessages(ScriptedOpenAIServer model) throws Exception {
|
||||
String last = model.requests.get(model.requests.size() - 1);
|
||||
return FirstAgentTest.JSON.readTree(last).get("messages");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failuresReachTheModelAsText() throws Exception {
|
||||
var t = new Transcript("03-failure-modes.txt");
|
||||
|
||||
// 1. a tool throws
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenToolCall("c1", "getServiceStatus", "{\"service\":\"nope\"}").thenText("There is no such service.");
|
||||
String answer = OpsAgents.firstAgent(TestExecutors.openAI(model)).run("status of nope?");
|
||||
JsonNode msgs = lastMessages(model);
|
||||
JsonNode toolMsg = msgs.get(msgs.size() - 1);
|
||||
t.line("1. The tool throws IllegalArgumentException(\"Unknown service: nope\")");
|
||||
t.line(" agent.run returned: " + answer);
|
||||
t.line(" LLM calls: " + model.requests.size());
|
||||
t.line(" what the model was told: " + toolMsg.get("role").asText() + ": " + toolMsg.get("content").asText());
|
||||
assertThat(answer).isEqualTo("There is no such service.");
|
||||
assertThat(toolMsg.get("role").asText()).isEqualTo("tool");
|
||||
assertThat(toolMsg.get("content").asText()).contains("Unknown service: nope");
|
||||
}
|
||||
|
||||
// 2. the model sends arguments of the wrong type
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenToolCall("c1", "error_rate", "{\"service\":\"checkout\",\"minutes\":\"soon\"}").thenText("sorry");
|
||||
OpsAgents.firstAgent(TestExecutors.openAI(model)).run("error rate?");
|
||||
JsonNode msgs = lastMessages(model);
|
||||
JsonNode toolMsg = msgs.get(msgs.size() - 1);
|
||||
t.line("");
|
||||
t.line("2. The model sends {\"minutes\":\"soon\"} for an int parameter");
|
||||
t.line(" what the model was told: " + toolMsg.get("content").asText().replace("\n", " | "));
|
||||
assertThat(toolMsg.get("content").asText()).contains("failed to parse arguments");
|
||||
}
|
||||
|
||||
// 3. the model calls a tool that was never registered
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenToolCall("c1", "restartService", "{\"service\":\"checkout\"}").thenText("ok");
|
||||
OpsAgents.firstAgent(TestExecutors.openAI(model)).run("restart checkout");
|
||||
JsonNode msgs = lastMessages(model);
|
||||
t.line("");
|
||||
t.line("3. The model calls restartService, which is not registered");
|
||||
t.line(" the history sent back, last two messages:");
|
||||
for (int i = msgs.size() - 2; i < msgs.size(); i++) t.line(" " + msgs.get(i));
|
||||
assertThat(msgs.get(msgs.size() - 1).get("content").asText()).contains("not found in the tool registry");
|
||||
}
|
||||
|
||||
// 4. the model never stops calling tools
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
for (int i = 0; i < 20; i++) model.thenToolCall("c" + i, "getServiceStatus", "{\"service\":\"search\"}");
|
||||
AIAgent<String, String> agent = AIAgent.builder()
|
||||
.promptExecutor(TestExecutors.openAI(model))
|
||||
.llmModel(OpenAIModels.Chat.GPT4o)
|
||||
.toolRegistry(ToolRegistry.builder().tools(new ServiceTools()).build())
|
||||
.maxIterations(6)
|
||||
.build();
|
||||
t.line("");
|
||||
t.line("4. The model answers every request with another tool call, maxIterations(6)");
|
||||
Throwable thrown = null;
|
||||
try { agent.run("loop"); } catch (Throwable e) { thrown = e; }
|
||||
t.line(" " + thrown.getClass().getSimpleName());
|
||||
t.line(" LLM calls made before it stopped: " + model.requests.size());
|
||||
t.line(" default maxIterations of an agent you do not configure: "
|
||||
+ OpsAgents.firstAgent(TestExecutors.openAI(model)).getAgentConfig().getMaxAgentIterations());
|
||||
assertThat(thrown).isInstanceOf(AIAgentMaxNumberOfIterationsReachedException.class);
|
||||
assertThat(model.requests.size()).isEqualTo(3);
|
||||
}
|
||||
t.write();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAgentInstanceCanRunTwice_andStartsFresh() throws Exception {
|
||||
var t = new Transcript("04-agent-reuse.txt");
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenText("first").thenText("second");
|
||||
AIAgent<String, String> agent = AIAgent.builder()
|
||||
.promptExecutor(TestExecutors.openAI(model))
|
||||
.llmModel(OpenAIModels.Chat.GPT4o)
|
||||
.build();
|
||||
String first = agent.run("hello");
|
||||
String second = agent.run("hello again");
|
||||
t.line("run 1: " + first);
|
||||
t.line("run 2: " + second);
|
||||
JsonNode run2 = FirstAgentTest.JSON.readTree(model.requests.get(1)).get("messages");
|
||||
t.line("messages sent on run 2: " + run2.size());
|
||||
run2.forEach(m -> t.line(" " + m));
|
||||
assertThat(first).isEqualTo("first");
|
||||
assertThat(second).isEqualTo("second");
|
||||
// a second run() starts from a fresh history; run 1 does not leak into it
|
||||
assertThat(run2).hasSize(1);
|
||||
}
|
||||
t.write();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.agents.core.agent.AIAgent;
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FirstAgentTest {
|
||||
|
||||
static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void agentCallsATool_thenAnswers() throws Exception {
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenToolCall("call_1", "getServiceStatus", "{\"service\":\"checkout\"}")
|
||||
.thenText("checkout is DEGRADED");
|
||||
PromptExecutor executor = TestExecutors.openAI(model);
|
||||
AIAgent<String, String> agent = OpsAgents.firstAgent(executor);
|
||||
|
||||
String answer = agent.run("Is checkout healthy?");
|
||||
|
||||
var t = new Transcript("01-first-agent-on-the-wire.txt");
|
||||
t.line("agent.run(\"Is checkout healthy?\") -> " + answer);
|
||||
t.line("");
|
||||
t.line("HTTP calls the model endpoint received: " + model.paths.size());
|
||||
model.paths.forEach(p -> t.line(" " + p));
|
||||
|
||||
JsonNode first = JSON.readTree(model.requests.get(0));
|
||||
t.line("");
|
||||
t.line("Request 1 - messages:");
|
||||
first.get("messages").forEach(m -> t.line(" " + m.get("role").asText() + ": " + m.get("content").asText()));
|
||||
t.line("Request 1 - tools Koog derived from ServiceTools:");
|
||||
first.get("tools").forEach(tool -> t.line(" " + compact(tool.get("function"))));
|
||||
|
||||
JsonNode second = JSON.readTree(model.requests.get(1));
|
||||
t.line("");
|
||||
t.line("Request 2 - messages (the tool call and its result are now in the history):");
|
||||
second.get("messages").forEach(m -> t.line(" " + compact(m)));
|
||||
t.write();
|
||||
|
||||
assertThat(answer).isEqualTo("checkout is DEGRADED");
|
||||
assertThat(model.paths).containsExactly("POST /v1/chat/completions", "POST /v1/chat/completions");
|
||||
assertThat(first.get("tools")).hasSize(2);
|
||||
assertThat(first.get("tools").get(0).get("function").get("name").asText()).isEqualTo("getServiceStatus");
|
||||
// customName wins over the method name
|
||||
assertThat(first.get("tools").get(1).get("function").get("name").asText()).isEqualTo("error_rate");
|
||||
JsonNode toolMessage = second.get("messages").get(3);
|
||||
assertThat(toolMessage.get("role").asText()).isEqualTo("tool");
|
||||
assertThat(toolMessage.get("content").asText()).isEqualTo("\"DEGRADED\"");
|
||||
}
|
||||
}
|
||||
|
||||
static String compact(JsonNode n) {
|
||||
return n.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
|
||||
/**
|
||||
* A stand-in for api.openai.com: a local HTTP server that speaks the chat-completions wire format
|
||||
* and answers from a script. Koog's real OpenAI client, tool serialisation and agent loop all run;
|
||||
* only the model is scripted. Every request body is recorded so tests can show what Koog sent.
|
||||
*/
|
||||
final class ScriptedOpenAIServer implements AutoCloseable {
|
||||
|
||||
private final HttpServer server;
|
||||
private final Queue<String> script = new ArrayDeque<>();
|
||||
final List<String> requests = Collections.synchronizedList(new ArrayList<>());
|
||||
final List<String> paths = Collections.synchronizedList(new ArrayList<>());
|
||||
|
||||
ScriptedOpenAIServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||
paths.add(exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath());
|
||||
requests.add(body);
|
||||
String reply = script.poll();
|
||||
if (reply == null) {
|
||||
reply = text("(script exhausted)");
|
||||
}
|
||||
byte[] bytes = reply.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
String baseUrl() { return "http://127.0.0.1:" + server.getAddress().getPort(); }
|
||||
|
||||
ScriptedOpenAIServer thenToolCall(String id, String tool, String argumentsJson) {
|
||||
script.add(toolCall(id, tool, argumentsJson));
|
||||
return this;
|
||||
}
|
||||
|
||||
ScriptedOpenAIServer thenText(String content) {
|
||||
script.add(text(content));
|
||||
return this;
|
||||
}
|
||||
|
||||
static String text(String content) {
|
||||
return """
|
||||
{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o",
|
||||
"choices":[{"index":0,"finish_reason":"stop",
|
||||
"message":{"role":"assistant","content":%s}}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
|
||||
""".formatted(quote(content));
|
||||
}
|
||||
|
||||
static String toolCall(String id, String tool, String argumentsJson) {
|
||||
return """
|
||||
{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"gpt-4o",
|
||||
"choices":[{"index":0,"finish_reason":"tool_calls",
|
||||
"message":{"role":"assistant","content":null,
|
||||
"tool_calls":[{"id":%s,"type":"function",
|
||||
"function":{"name":%s,"arguments":%s}}]}}],
|
||||
"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
|
||||
""".formatted(quote(id), quote(tool), quote(argumentsJson));
|
||||
}
|
||||
|
||||
private static String quote(String s) {
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
|
||||
@Override public void close() { server.stop(0); }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class SpringBootStarterTest {
|
||||
|
||||
static ScriptedOpenAIServer model;
|
||||
|
||||
static {
|
||||
try { model = new ScriptedOpenAIServer(); } catch (IOException e) { throw new IllegalStateException(e); }
|
||||
model.thenToolCall("call_1", "error_rate", "{\"service\":\"checkout\",\"minutes\":15}")
|
||||
.thenText("checkout fails 4.2% of requests over 15 minutes");
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void koog(DynamicPropertyRegistry registry) {
|
||||
registry.add("ai.koog.openai.api-key", () -> "test-key");
|
||||
registry.add("ai.koog.openai.base-url", model::baseUrl);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stop() { model.close(); }
|
||||
|
||||
@Autowired ApplicationContext context;
|
||||
@Autowired TestRestTemplate http;
|
||||
|
||||
@Test
|
||||
void theStarterWiresAnExecutor_andTheServiceBuildsTheAgent() {
|
||||
String body = http.getForObject("/ask?q={q}", String.class, "How bad are checkout errors?");
|
||||
|
||||
var t = new Transcript("05-spring-boot-starter.txt");
|
||||
t.line("PromptExecutor beans the starter registered (only OpenAI is configured):");
|
||||
Arrays.stream(context.getBeanNamesForType(PromptExecutor.class)).sorted().forEach(n -> t.line(" " + n));
|
||||
t.line("");
|
||||
t.line("GET /ask?q=How bad are checkout errors?");
|
||||
t.line(" -> " + body);
|
||||
t.line("Model endpoint saw: " + model.paths);
|
||||
t.line("Tool argument the model sent: minutes=15 (an int, parsed by Koog)");
|
||||
t.line("");
|
||||
t.line("context.getBean(PromptExecutor.class) with one provider configured:");
|
||||
try {
|
||||
context.getBean(PromptExecutor.class);
|
||||
} catch (NoUniqueBeanDefinitionException e) {
|
||||
t.line(" " + e.getClass().getSimpleName() + ": expected single matching bean but found 2: "
|
||||
+ String.join(",", e.getBeanNamesFound().stream().sorted().toList()));
|
||||
assertThat(e.getBeanNamesFound()).containsExactlyInAnyOrder("openAIExecutor", "multiLLMPromptExecutor");
|
||||
}
|
||||
try { t.write(); } catch (IOException e) { throw new IllegalStateException(e); }
|
||||
|
||||
assertThat(body).isEqualTo("checkout fails 4.2% of requests over 15 minutes");
|
||||
assertThat(context.getBeanNamesForType(PromptExecutor.class)).contains("openAIExecutor");
|
||||
assertThat(model.paths).containsExactly("POST /v1/chat/completions", "POST /v1/chat/completions");
|
||||
assertThat(model.requests.get(1)).contains("\"content\":\"4.2\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnApiKeyThereIsNoExecutor_andTheServiceCannotStart() {
|
||||
Throwable failure = null;
|
||||
try (var ignored = new SpringApplicationBuilder(OpsApplication.class)
|
||||
.properties("server.port=0", "ai.koog.openai.api-key=", "ai.koog.openai.enabled=true")
|
||||
.run()) {
|
||||
// not reached
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
Throwable root = failure;
|
||||
while (root != null && root.getCause() != null && !(root instanceof org.springframework.beans.factory.NoSuchBeanDefinitionException)) {
|
||||
root = root.getCause();
|
||||
}
|
||||
var t = new Transcript("06-spring-boot-no-api-key.txt");
|
||||
t.line("ai.koog.openai.api-key is empty, ai.koog.openai.enabled=true is set:");
|
||||
t.line(" startup failed: " + (failure != null));
|
||||
t.line(" cause: " + (root == null ? "none" : root.getClass().getSimpleName()));
|
||||
t.line(" message: " + (root == null ? "none" : root.getMessage()));
|
||||
try { t.write(); } catch (IOException e) { throw new IllegalStateException(e); }
|
||||
assertThat(failure).isNotNull();
|
||||
assertThat(root).isInstanceOf(org.springframework.beans.factory.NoSuchBeanDefinitionException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class StrategyGraphTest {
|
||||
|
||||
@Test
|
||||
void aJavaNodeRunsBeforeTheModel_andTheGraphRendersAsMermaid() throws Exception {
|
||||
try (var model = new ScriptedOpenAIServer()) {
|
||||
model.thenToolCall("call_1", "getServiceStatus", "{\"service\":\"checkout\"}")
|
||||
.thenText("checkout is DEGRADED");
|
||||
var agent = OpsAgents.redactingAgent(TestExecutors.openAI(model));
|
||||
|
||||
String answer = agent.run("My key is sk-ABCDEFGH12345678. Is checkout healthy?");
|
||||
|
||||
JsonNode first = FirstAgentTest.JSON.readTree(model.requests.get(0));
|
||||
String userMessage = first.get("messages").get(1).get("content").asText();
|
||||
|
||||
var t = new Transcript("02-strategy-graph-and-redaction.txt");
|
||||
t.line("Graph, as MermaidDiagramGenerator prints it:");
|
||||
t.line("");
|
||||
for (String l : OpsAgents.diagram().split("\n")) t.line(l);
|
||||
t.line("");
|
||||
t.line("The user typed: My key is sk-ABCDEFGH12345678. Is checkout healthy?");
|
||||
t.line("The model got: " + userMessage);
|
||||
t.line("Answer: " + answer);
|
||||
t.line("Secret present in any request body: "
|
||||
+ model.requests.stream().anyMatch(r -> r.contains("sk-ABCDEFGH12345678")));
|
||||
t.write();
|
||||
|
||||
assertThat(userMessage).isEqualTo("My key is [REDACTED]. Is checkout healthy?");
|
||||
assertThat(model.requests).noneMatch(r -> r.contains("sk-ABCDEFGH12345678"));
|
||||
assertThat(answer).isEqualTo("checkout is DEGRADED");
|
||||
assertThat(OpsAgents.diagram()).contains("[*] --> redact", "redact --> callLLM");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.prompt.executor.clients.ConnectionTimeoutConfig;
|
||||
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings;
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
|
||||
/** A PromptExecutor whose OpenAI client talks to the scripted server instead of api.openai.com. */
|
||||
final class TestExecutors {
|
||||
private TestExecutors() {}
|
||||
|
||||
static PromptExecutor openAI(ScriptedOpenAIServer model) {
|
||||
var settings = new OpenAIClientSettings(
|
||||
model.baseUrl(), new ConnectionTimeoutConfig(),
|
||||
"v1/chat/completions", "v1/responses", "v1/embeddings", "v1/moderations", "v1/models");
|
||||
return PromptExecutor.builder().openAI("test-key", settings).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
|
||||
final class Transcript {
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final String file;
|
||||
|
||||
Transcript(String file) { this.file = file; }
|
||||
|
||||
void line(String s) { lines.add(s); }
|
||||
|
||||
void write() throws IOException {
|
||||
Path dir = Path.of("output");
|
||||
Files.createDirectories(dir);
|
||||
Files.write(dir.resolve(file), lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.koog;
|
||||
|
||||
import ai.koog.prompt.executor.model.PromptExecutor;
|
||||
|
||||
/** Only used by scripts/kotlin-bom-trap.sh: the first Koog call that touches kotlin.time. */
|
||||
public class TrapMain {
|
||||
public static void main(String[] args) {
|
||||
PromptExecutor.builder().openAI("k").build();
|
||||
System.out.println("no error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<configuration>
|
||||
<!-- Koog logs a full stack trace at ERROR for every tool failure it hands back to the model.
|
||||
That is useful in production and drowns the test report, so the tests keep it off. -->
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder><pattern>%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n</pattern></encoder>
|
||||
</appender>
|
||||
<logger name="ai.koog" level="OFF"/>
|
||||
<root level="WARN"><appender-ref ref="STDOUT"/></root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user