kosong
Kosong is an LLM abstraction layer designed for modern AI agent applications. It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you can build agents with ease and avoid vendor lock-in.
Key features:
kosong.generatecreates a completion stream and merges streamed message parts (including content and tool calls) from anyChatProviderinto a completeMessageplus optionalTokenUsage.kosong.steplayers tool dispatch (Tool,Toolset,SimpleToolset) overgenerate, exposingStepResultwith awaited tool outputs and streaming callbacks.- Message structures and tool abstractions live under
kosong.messageandkosong.tooling.
Example:
import asyncio
from pydantic import BaseModel
import kosong
from kosong import StepResult
from kosong.chat_provider.kimi import Kimi
from kosong.message import Message
from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue
from kosong.tooling.simple import SimpleToolset
class AddToolParams(BaseModel):
a: int
b: int
class AddTool(CallableTool2[AddToolParams]):
name: str = "add"
description: str = "Add two integers."
params: type[AddToolParams] = AddToolParams
async def __call__(self, params: AddToolParams) -> ToolReturnValue:
return ToolOk(output=str(params.a + params.b))
async def main() -> None:
kimi = Kimi(
base_url="https://api.moonshot.ai/v1",
api_key="your_kimi_api_key_here",
model="kimi-k2-turbo-preview",
)
toolset = SimpleToolset()
toolset += AddTool()
history = [
Message(role="user", content="Please add 2 and 3 with the add tool."),
]
result: StepResult = await kosong.step(
chat_provider=kimi,
system_prompt="You are a precise math tutor.",
toolset=toolset,
history=history,
)
print(result.message)
print(await result.tool_results())
asyncio.run(main())
1""" 2Kosong is an LLM abstraction layer designed for modern AI agent applications. 3It unifies message structures, asynchronous tool orchestration, and pluggable chat providers so you 4can build agents with ease and avoid vendor lock-in. 5 6Key features: 7 8- `kosong.generate` creates a completion stream and merges streamed message parts (including 9 content and tool calls) from any `ChatProvider` into a complete `Message` plus optional 10 `TokenUsage`. 11- `kosong.step` layers tool dispatch (`Tool`, `Toolset`, `SimpleToolset`) over `generate`, 12 exposing `StepResult` with awaited tool outputs and streaming callbacks. 13- Message structures and tool abstractions live under `kosong.message` and `kosong.tooling`. 14 15Example: 16 17```python 18import asyncio 19 20from pydantic import BaseModel 21 22import kosong 23from kosong import StepResult 24from kosong.chat_provider.kimi import Kimi 25from kosong.message import Message 26from kosong.tooling import CallableTool2, ToolOk, ToolReturnValue 27from kosong.tooling.simple import SimpleToolset 28 29 30class AddToolParams(BaseModel): 31 a: int 32 b: int 33 34 35class AddTool(CallableTool2[AddToolParams]): 36 name: str = "add" 37 description: str = "Add two integers." 38 params: type[AddToolParams] = AddToolParams 39 40 async def __call__(self, params: AddToolParams) -> ToolReturnValue: 41 return ToolOk(output=str(params.a + params.b)) 42 43 44async def main() -> None: 45 kimi = Kimi( 46 base_url="https://api.moonshot.ai/v1", 47 api_key="your_kimi_api_key_here", 48 model="kimi-k2-turbo-preview", 49 ) 50 51 toolset = SimpleToolset() 52 toolset += AddTool() 53 54 history = [ 55 Message(role="user", content="Please add 2 and 3 with the add tool."), 56 ] 57 58 result: StepResult = await kosong.step( 59 chat_provider=kimi, 60 system_prompt="You are a precise math tutor.", 61 toolset=toolset, 62 history=history, 63 ) 64 print(result.message) 65 print(await result.tool_results()) 66 67 68asyncio.run(main()) 69``` 70""" 71 72import asyncio 73from collections.abc import Callable, Sequence 74from dataclasses import dataclass 75 76from loguru import logger 77 78from kosong._generate import GenerateResult, generate 79from kosong.chat_provider import ChatProvider, ChatProviderError, StreamedMessagePart, TokenUsage 80from kosong.message import Message, ToolCall 81from kosong.tooling import ToolResult, ToolResultFuture, Toolset 82from kosong.utils.aio import Callback 83 84# Explicitly import submodules 85from . import chat_provider, contrib, message, tooling, utils 86 87logger.disable("kosong") 88 89__all__ = [ 90 # submodules 91 "chat_provider", 92 "tooling", 93 "message", 94 "utils", 95 "contrib", 96 # classes and functions 97 "generate", 98 "GenerateResult", 99 "step", 100 "StepResult", 101] 102 103 104async def step( 105 chat_provider: ChatProvider, 106 system_prompt: str, 107 toolset: Toolset, 108 history: Sequence[Message], 109 *, 110 on_message_part: Callback[[StreamedMessagePart], None] | None = None, 111 on_tool_result: Callable[[ToolResult], None] | None = None, 112 on_trace_id: Callback[[str | None], None] | None = None, 113) -> "StepResult": 114 """ 115 Run one agent "step". In one step, the function generates LLM response based on the given 116 context for exactly one time. All new message parts will be streamed to `on_message_part` in 117 real-time if provided. Tool calls will be handled by `toolset`. The generated message will be 118 returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be 119 handled asynchronously and the results need to be fetched with `await result.tool_results()`. 120 121 The message history will NOT be modified in this function. 122 123 The token usage will be returned in the `StepResult` if available. 124 125 Raises: 126 APIConnectionError: If the API connection fails. 127 APITimeoutError: If the API request times out. 128 APIStatusError: If the API returns a status code of 4xx or 5xx. 129 APIEmptyResponseError: If the API returns an empty response. 130 ChatProviderError: If any other recognized chat provider error occurs. 131 asyncio.CancelledError: If the step is cancelled. 132 """ 133 134 tool_calls: list[ToolCall] = [] 135 tool_result_futures: dict[str, ToolResultFuture] = {} 136 137 def future_done_callback(future: ToolResultFuture): 138 if on_tool_result: 139 try: 140 result = future.result() 141 on_tool_result(result) 142 except asyncio.CancelledError: 143 return 144 145 async def on_tool_call(tool_call: ToolCall): 146 tool_calls.append(tool_call) 147 result = toolset.handle(tool_call) 148 149 if isinstance(result, ToolResult): 150 future = ToolResultFuture() 151 future.add_done_callback(future_done_callback) 152 future.set_result(result) 153 tool_result_futures[tool_call.id] = future 154 else: 155 result.add_done_callback(future_done_callback) 156 tool_result_futures[tool_call.id] = result 157 158 try: 159 result = await generate( 160 chat_provider, 161 system_prompt, 162 toolset.tools, 163 history, 164 on_message_part=on_message_part, 165 on_tool_call=on_tool_call, 166 on_trace_id=on_trace_id, 167 ) 168 except (ChatProviderError, asyncio.CancelledError): 169 # cancel all the futures to avoid hanging tasks 170 for future in tool_result_futures.values(): 171 future.remove_done_callback(future_done_callback) 172 future.cancel() 173 await asyncio.gather(*tool_result_futures.values(), return_exceptions=True) 174 raise 175 176 return StepResult( 177 result.id, 178 result.message, 179 result.usage, 180 tool_calls, 181 tool_result_futures, 182 trace_id=result.trace_id, 183 ) 184 185 186@dataclass(frozen=True, slots=True) 187class StepResult: 188 id: str | None 189 """The ID of the generated message.""" 190 191 message: Message 192 """The message generated in this step.""" 193 194 usage: TokenUsage | None 195 """The token usage in this step.""" 196 197 tool_calls: list[ToolCall] 198 """All the tool calls generated in this step.""" 199 200 _tool_result_futures: dict[str, ToolResultFuture] 201 """@private The futures of the results of the spawned tool calls.""" 202 203 trace_id: str | None = None 204 """The ``x-trace-id`` response header of the request, if the provider exposes it.""" 205 206 async def tool_results(self) -> list[ToolResult]: 207 """All the tool results returned by corresponding tool calls.""" 208 if not self._tool_result_futures: 209 return [] 210 211 try: 212 results: list[ToolResult] = [] 213 for tool_call in self.tool_calls: 214 future = self._tool_result_futures[tool_call.id] 215 result = await future 216 results.append(result) 217 return results 218 finally: 219 # one exception should cancel all the futures to avoid hanging tasks 220 for future in self._tool_result_futures.values(): 221 future.cancel() 222 await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
18async def generate( 19 chat_provider: ChatProvider, 20 system_prompt: str, 21 tools: Sequence[Tool], 22 history: Sequence[Message], 23 *, 24 on_message_part: Callback[[StreamedMessagePart], None] | None = None, 25 on_tool_call: Callback[[ToolCall], None] | None = None, 26 on_trace_id: Callback[[str | None], None] | None = None, 27) -> "GenerateResult": 28 """ 29 Generate one message based on the given context. 30 Parts of the message will be streamed to the specified callbacks if provided. 31 32 Args: 33 chat_provider: The chat provider to use for generation. 34 system_prompt: The system prompt to use for generation. 35 tools: The tools available for the model to call. 36 history: The message history to use for generation. 37 on_message_part: An optional callback to be called for each raw message part. 38 on_tool_call: An optional callback to be called for each complete tool call. 39 on_trace_id: An optional callback fired with the request's ``x-trace-id`` 40 response header as soon as it is available (before streaming starts). 41 42 Returns: 43 A tuple of the generated message and the token usage (if available). 44 All parts in the message are guaranteed to be complete and merged as much as possible. 45 46 Raises: 47 APIConnectionError: If the API connection fails. 48 APITimeoutError: If the API request times out. 49 APIStatusError: If the API returns a status code of 4xx or 5xx. 50 APIEmptyResponseError: If the API returns an empty response. 51 ChatProviderError: If any other recognized chat provider error occurs. 52 """ 53 message = Message(role="assistant", content=[]) 54 pending_part: StreamedMessagePart | None = None # message part that is currently incomplete 55 56 logger.trace("Generating with history: {history}", history=history) 57 stream = await chat_provider.generate(system_prompt, tools, history) 58 if on_trace_id: 59 # getattr for robustness against third-party StreamedMessage 60 # implementations that predate the trace_id property. 61 await callback(on_trace_id, getattr(stream, "trace_id", None)) 62 async for part in stream: 63 logger.trace("Received part: {part}", part=part) 64 if on_message_part: 65 await callback(on_message_part, part.model_copy(deep=True)) 66 67 if pending_part is None: 68 pending_part = part 69 elif not pending_part.merge_in_place(part): # try merge into the pending part 70 # unmergeable part must push the pending part to the buffer 71 _message_append(message, pending_part) 72 if isinstance(pending_part, ToolCall) and on_tool_call: 73 await callback(on_tool_call, pending_part) 74 pending_part = part 75 76 # end of message 77 if pending_part is not None: 78 _message_append(message, pending_part) 79 if isinstance(pending_part, ToolCall) and on_tool_call: 80 await callback(on_tool_call, pending_part) 81 82 if not message.content and not message.tool_calls: 83 raise APIEmptyResponseError("The API returned an empty response.") 84 85 # A response with only ThinkPart (no TextPart, no tool calls) indicates an 86 # abnormal termination — typically a stream interruption or max_tokens 87 # exhaustion during reasoning. The model should always produce visible 88 # output after thinking; a think-only response is never intentional. 89 has_think = any(isinstance(p, ThinkPart) for p in message.content) 90 has_text = any(isinstance(p, TextPart) and p.text.strip() for p in message.content) 91 if has_think and not has_text and not message.tool_calls: 92 raise APIEmptyResponseError( 93 "The API returned a response containing only thinking content " 94 "without any text or tool calls. This usually indicates the " 95 "stream was interrupted or the output token budget was exhausted " 96 "during reasoning." 97 ) 98 99 return GenerateResult( 100 id=stream.id, 101 message=message, 102 usage=stream.usage, 103 trace_id=getattr(stream, "trace_id", None), 104 )
Generate one message based on the given context. Parts of the message will be streamed to the specified callbacks if provided.
Arguments:
- chat_provider: The chat provider to use for generation.
- system_prompt: The system prompt to use for generation.
- tools: The tools available for the model to call.
- history: The message history to use for generation.
- on_message_part: An optional callback to be called for each raw message part.
- on_tool_call: An optional callback to be called for each complete tool call.
- on_trace_id: An optional callback fired with the request's
x-trace-idresponse header as soon as it is available (before streaming starts).
Returns:
A tuple of the generated message and the token usage (if available). All parts in the message are guaranteed to be complete and merged as much as possible.
Raises:
- APIConnectionError: If the API connection fails.
- APITimeoutError: If the API request times out.
- APIStatusError: If the API returns a status code of 4xx or 5xx.
- APIEmptyResponseError: If the API returns an empty response.
- ChatProviderError: If any other recognized chat provider error occurs.
107@dataclass(frozen=True, slots=True) 108class GenerateResult: 109 """The result of a generation.""" 110 111 id: str | None 112 """The ID of the generated message.""" 113 message: Message 114 """The generated message.""" 115 usage: TokenUsage | None 116 """The token usage of the generated message.""" 117 trace_id: str | None = None 118 """The ``x-trace-id`` response header of the request, if the provider exposes it."""
The result of a generation.
105async def step( 106 chat_provider: ChatProvider, 107 system_prompt: str, 108 toolset: Toolset, 109 history: Sequence[Message], 110 *, 111 on_message_part: Callback[[StreamedMessagePart], None] | None = None, 112 on_tool_result: Callable[[ToolResult], None] | None = None, 113 on_trace_id: Callback[[str | None], None] | None = None, 114) -> "StepResult": 115 """ 116 Run one agent "step". In one step, the function generates LLM response based on the given 117 context for exactly one time. All new message parts will be streamed to `on_message_part` in 118 real-time if provided. Tool calls will be handled by `toolset`. The generated message will be 119 returned in a `StepResult`. Depending on the toolset implementation, the tool calls may be 120 handled asynchronously and the results need to be fetched with `await result.tool_results()`. 121 122 The message history will NOT be modified in this function. 123 124 The token usage will be returned in the `StepResult` if available. 125 126 Raises: 127 APIConnectionError: If the API connection fails. 128 APITimeoutError: If the API request times out. 129 APIStatusError: If the API returns a status code of 4xx or 5xx. 130 APIEmptyResponseError: If the API returns an empty response. 131 ChatProviderError: If any other recognized chat provider error occurs. 132 asyncio.CancelledError: If the step is cancelled. 133 """ 134 135 tool_calls: list[ToolCall] = [] 136 tool_result_futures: dict[str, ToolResultFuture] = {} 137 138 def future_done_callback(future: ToolResultFuture): 139 if on_tool_result: 140 try: 141 result = future.result() 142 on_tool_result(result) 143 except asyncio.CancelledError: 144 return 145 146 async def on_tool_call(tool_call: ToolCall): 147 tool_calls.append(tool_call) 148 result = toolset.handle(tool_call) 149 150 if isinstance(result, ToolResult): 151 future = ToolResultFuture() 152 future.add_done_callback(future_done_callback) 153 future.set_result(result) 154 tool_result_futures[tool_call.id] = future 155 else: 156 result.add_done_callback(future_done_callback) 157 tool_result_futures[tool_call.id] = result 158 159 try: 160 result = await generate( 161 chat_provider, 162 system_prompt, 163 toolset.tools, 164 history, 165 on_message_part=on_message_part, 166 on_tool_call=on_tool_call, 167 on_trace_id=on_trace_id, 168 ) 169 except (ChatProviderError, asyncio.CancelledError): 170 # cancel all the futures to avoid hanging tasks 171 for future in tool_result_futures.values(): 172 future.remove_done_callback(future_done_callback) 173 future.cancel() 174 await asyncio.gather(*tool_result_futures.values(), return_exceptions=True) 175 raise 176 177 return StepResult( 178 result.id, 179 result.message, 180 result.usage, 181 tool_calls, 182 tool_result_futures, 183 trace_id=result.trace_id, 184 )
Run one agent "step". In one step, the function generates LLM response based on the given
context for exactly one time. All new message parts will be streamed to on_message_part in
real-time if provided. Tool calls will be handled by toolset. The generated message will be
returned in a StepResult. Depending on the toolset implementation, the tool calls may be
handled asynchronously and the results need to be fetched with await result.tool_results().
The message history will NOT be modified in this function.
The token usage will be returned in the StepResult if available.
Raises:
- APIConnectionError: If the API connection fails.
- APITimeoutError: If the API request times out.
- APIStatusError: If the API returns a status code of 4xx or 5xx.
- APIEmptyResponseError: If the API returns an empty response.
- ChatProviderError: If any other recognized chat provider error occurs.
- asyncio.CancelledError: If the step is cancelled.
187@dataclass(frozen=True, slots=True) 188class StepResult: 189 id: str | None 190 """The ID of the generated message.""" 191 192 message: Message 193 """The message generated in this step.""" 194 195 usage: TokenUsage | None 196 """The token usage in this step.""" 197 198 tool_calls: list[ToolCall] 199 """All the tool calls generated in this step.""" 200 201 _tool_result_futures: dict[str, ToolResultFuture] 202 """@private The futures of the results of the spawned tool calls.""" 203 204 trace_id: str | None = None 205 """The ``x-trace-id`` response header of the request, if the provider exposes it.""" 206 207 async def tool_results(self) -> list[ToolResult]: 208 """All the tool results returned by corresponding tool calls.""" 209 if not self._tool_result_futures: 210 return [] 211 212 try: 213 results: list[ToolResult] = [] 214 for tool_call in self.tool_calls: 215 future = self._tool_result_futures[tool_call.id] 216 result = await future 217 results.append(result) 218 return results 219 finally: 220 # one exception should cancel all the futures to avoid hanging tasks 221 for future in self._tool_result_futures.values(): 222 future.cancel() 223 await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
207 async def tool_results(self) -> list[ToolResult]: 208 """All the tool results returned by corresponding tool calls.""" 209 if not self._tool_result_futures: 210 return [] 211 212 try: 213 results: list[ToolResult] = [] 214 for tool_call in self.tool_calls: 215 future = self._tool_result_futures[tool_call.id] 216 result = await future 217 results.append(result) 218 return results 219 finally: 220 # one exception should cancel all the futures to avoid hanging tasks 221 for future in self._tool_result_futures.values(): 222 future.cancel() 223 await asyncio.gather(*self._tool_result_futures.values(), return_exceptions=True)
All the tool results returned by corresponding tool calls.