mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-28 23:01:34 +00:00
131 lines
5.3 KiB
Swift
131 lines
5.3 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Presentation aliases only. Keep the recorded name/input and chat grouping intact.
|
|
func toolPresentationName(_ name: String) -> String {
|
|
let name = name.hasPrefix("functions.") ? String(name.dropFirst("functions.".count)) : name
|
|
switch name {
|
|
case "exec_command": return "Bash"
|
|
case "apply_patch": return "CodexPatch"
|
|
default: return name
|
|
}
|
|
}
|
|
|
|
let terminalToolNames: Set<String> = [
|
|
"Bash", "CodexBash", "shell_command", "run_shell_command", "write_stdin",
|
|
]
|
|
|
|
func toolSourceInput(_ input: JSONValue?, keys: [String]) -> String? {
|
|
input?.chatString ?? chatInputString(input, keys)
|
|
}
|
|
|
|
/// Bounded wrapper traversal; mixed/non-text blocks deliberately retain their
|
|
/// JSON representation rather than silently dropping images or resources.
|
|
func extractResultText(_ result: JSONValue, depth: Int = 0) -> String? {
|
|
guard depth <= 4 else { return nil }
|
|
switch result {
|
|
case .string(let text): return text
|
|
case .array(let entries):
|
|
var texts: [String] = []
|
|
for entry in entries {
|
|
if let text = entry.chatString {
|
|
texts.append(text)
|
|
} else if let object = entry.chatObject,
|
|
object["type"] == nil || object["type"]?.chatString == "text",
|
|
let text = object["text"]?.chatString {
|
|
texts.append(text)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
return texts.joined(separator: "\n")
|
|
case .object(let object):
|
|
let stdout = object["stdout"]?.chatString
|
|
let stderr = object["stderr"]?.chatString
|
|
if stdout != nil || stderr != nil {
|
|
var parts: [String] = []
|
|
if let out = stdout, !out.isEmpty { parts.append(out) }
|
|
if let err = stderr, !err.isEmpty { parts.append("stderr:\n\(err)") }
|
|
if !parts.isEmpty { return parts.joined(separator: "\n\n") }
|
|
// Empty stdout must not hide a structured error.
|
|
}
|
|
if let content = object["file"]?[chatKey: "content"]?.chatString { return content }
|
|
var emptyText: String?
|
|
for key in ["content", "text", "output", "error", "message", "result", "data"] {
|
|
if let value = object[key], value != .null {
|
|
let text = extractResultText(value, depth: depth + 1)
|
|
if let text, !text.isEmpty { return text }
|
|
if text != nil { emptyText = "" }
|
|
// Preserve a mixed content array in full, not just another field.
|
|
if key == "content", case .array = value, text == nil {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
if stdout != nil || stderr != nil { return "" }
|
|
return emptyText
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
enum ToolResultStyle: Equatable {
|
|
case terminal
|
|
case code(String?)
|
|
case markdown
|
|
}
|
|
|
|
func toolResultStyle(_ tool: ChatToolCall) -> ToolResultStyle {
|
|
let name = toolPresentationName(tool.name)
|
|
// Errors are literal diagnostics, not markdown or source code.
|
|
if tool.state == .error { return .terminal }
|
|
let parsed = tool.input?[chatKey: "parsed_cmd"]?.chatArray
|
|
let readCommand = name == "CodexBash" && parsed?.count == 1 && parsed?.first?[chatKey: "type"]?.chatString == "read"
|
|
if ["Read", "NotebookRead"].contains(name) || readCommand {
|
|
let file = tool.result?[chatKey: "file"]
|
|
let path = chatInputString(file, ["filePath", "file_path"])
|
|
?? chatInputString(tool.input, ["file_path", "path", "file", "notebook_path"])
|
|
?? (readCommand ? chatInputString(parsed?.first, ["name", "path", "file_path"]) : nil)
|
|
return .code(languageForPath(path))
|
|
}
|
|
if ["WebFetch", "WebSearch", "Task", "Agent", "Skill", "ExitPlanMode", "exit_plan_mode"].contains(name) {
|
|
return .markdown
|
|
}
|
|
return .terminal
|
|
}
|
|
|
|
func toolQuestionOptionText(_ option: JSONValue) -> String {
|
|
if let text = option.chatString { return text }
|
|
if let label = chatInputString(option, ["label", "value"]) {
|
|
if let description = chatInputString(option, ["description"]), !description.isEmpty {
|
|
return "\(label) — \(description)"
|
|
}
|
|
return label
|
|
}
|
|
return chatPrettyJSON(option)
|
|
}
|
|
|
|
/// Keep command status visible even when stdout is empty. Technical wire labels
|
|
/// avoid conflating the process exit code with the tool call's transport state.
|
|
func toolResultMetadata(_ result: JSONValue?, depth: Int = 0) -> [String] {
|
|
guard depth <= 4, let object = result?.chatObject else { return [] }
|
|
let fields = [("exit_code", ["exit_code", "exitCode"]), ("status", ["status"]),
|
|
("session_id", ["session_id"]), ("wall_time_seconds", ["wall_time_seconds"])]
|
|
let metadata = fields.compactMap { label, keys -> String? in
|
|
for key in keys {
|
|
guard let value = object[key] else { continue }
|
|
switch value {
|
|
case .string(let text) where !text.isEmpty: return "\(label): \(text)"
|
|
case .number: return "\(label): \(chatPrettyJSON(value))"
|
|
default: continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if !metadata.isEmpty { return metadata }
|
|
for key in ["output", "result", "data"] {
|
|
let nested = toolResultMetadata(object[key], depth: depth + 1)
|
|
if !nested.isEmpty { return nested }
|
|
}
|
|
return []
|
|
}
|