Files
luma/Sources/LumaCore/Agentic/PatternTools.swift
Ole André Vadla Ravnås 69c706789f Follow the pattern compiler's module API
Compile, then decode through the module. Pass platform before arch,
and default both to the host rather than darwin/arm64.
2026-10-01 20:49:11 +02:00

302 lines
16 KiB
Swift

import Foundation
extension MissionTools {
static func registerPatterns(in catalog: ToolCatalog, engine: Engine) {
registerListPatterns(in: catalog, engine: engine)
registerReadPattern(in: catalog, engine: engine)
registerCheckPattern(in: catalog, engine: engine)
registerWritePattern(in: catalog, engine: engine)
registerDecodeMemory(in: catalog, engine: engine)
registerCallPatternFunction(in: catalog, engine: engine)
registerPlacePattern(in: catalog, engine: engine)
}
private static let patternSourceSchema = """
"pattern_id":{"type":"string","description":"Library file, as list_patterns names it, e.g. \\"macho.hexpat\\""},\
"source":{"type":"string","description":"Inline pattern source, instead of pattern_id"},\
"type":{"type":"string","description":"Type to decode; defaults to the source's root placement"}
"""
private static func registerListPatterns(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "list_patterns",
description: """
List the pattern library: ImHex-style pattern language files (.hexpat patterns, .pat \
libraries) with the types each declares and any compile errors. Patterns describe binary \
layouts; decode_memory applies one to process memory.
""",
inputSchemaJSON: """
{"type":"object","properties":{},"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] _ in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
let listed = await engine.describePatternLibrary()
return makeResult(jsonObject: listed, summary: "\(listed.count) pattern file\(listed.count == 1 ? "" : "s")")
}
}
private static func registerReadPattern(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "read_pattern",
description: "Read a pattern library file's source.",
inputSchemaJSON: """
{"type":"object","properties":{"pattern_id":{"type":"string"}},"required":["pattern_id"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let id = invocation.args["pattern_id"] as? String, let source = engine.patterns.source(withID: id) else {
return errorResult("no pattern file with that pattern_id; see list_patterns", code: .notFound)
}
return makeResult(jsonObject: ["id": source.id, "source": source.text], summary: "Read \(source.id)")
}
}
private static func registerCheckPattern(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "check_pattern",
description: """
Compile pattern source without saving it, and report its diagnostics and the types it \
declares. Use to iterate on a pattern before write_pattern.
""",
inputSchemaJSON: """
{"type":"object","properties":{"source":{"type":"string"}},"required":["source"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: false
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let text = invocation.args["source"] as? String else {
return errorResult("source is required", code: .invalidInput)
}
do {
let summary = try await engine.patternDecoder.summary(ofText: text)
return makeResult(
jsonObject: summary.jsonObject,
summary: summary.diagnostics.isEmpty ? "Compiles cleanly" : "\(summary.diagnostics.count) diagnostic(s)")
} catch {
return errorResult("compile failed: \(error.localizedDescription)", code: .failed)
}
}
}
private static func registerWritePattern(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "write_pattern",
description: """
Create or replace a pattern library file. The id is a file name ending in .hexpat \
(a pattern) or .pat (a library other files import). Returns the diagnostics of what was \
written. Requires user approval.
""",
inputSchemaJSON: """
{"type":"object","properties":{"pattern_id":{"type":"string","description":"e.g. \\"elf.hexpat\\""},"source":{"type":"string"}},"required":["pattern_id","source"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: false,
codePreview: CodePreviewArg(field: "source", language: .patternLanguage)
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let id = invocation.args["pattern_id"] as? String, let text = invocation.args["source"] as? String else {
return errorResult("pattern_id and source are required", code: .invalidInput)
}
let url = URL(fileURLWithPath: id)
guard id == url.lastPathComponent, !id.hasPrefix("."), PatternSource.extensions.contains(url.pathExtension.lowercased()) else {
return errorResult("pattern_id must be a plain file name ending in .hexpat or .pat", code: .invalidInput)
}
do {
try engine.patterns.write(text, to: id)
let summary = try await engine.patternDecoder.summary(ofText: text)
var payload = summary.jsonObject
payload["id"] = id
return makeResult(jsonObject: payload, summary: "Wrote \(id)")
} catch {
return errorResult("write failed: \(error.localizedDescription)", code: .failed)
}
}
}
private static func registerDecodeMemory(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "decode_memory",
description: """
Decode process memory at an address with a pattern type, and return the decoded tree: \
each node's name, type, address, size and formatted value. Memory is read as far as the \
type needs, up to 1 MiB. The tree is cut at 'depth' levels, with arrays showing their \
first 'element_limit' elements; use 'path' (e.g. \\"commands[3].segname\\") to start \
deeper instead of widening the whole tree.
""",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address"},\
\(patternSourceSchema),\
"path":{"type":"string","description":"Node to return, as field names and [index] steps from the root"},\
"depth":{"type":"integer","minimum":0,"maximum":8,"default":2},\
"element_limit":{"type":"integer","minimum":0,"maximum":256,"default":8}},\
"required":["session_id","address"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
do {
let request = try await PatternRequest(invocation.args, engine: engine)
let decoding = try await request.decode(engine: engine)
let node = try decoding.value.descendant(at: request.path)
var payload = node.projection(
depth: min((invocation.args["depth"] as? Int) ?? 2, 8),
elementLimit: min((invocation.args["element_limit"] as? Int) ?? 8, 256))
payload["bytes_read"] = decoding.data.count
return makeResult(
jsonObject: payload, summary: "Decoded \(request.typeName) at \(String(format: "0x%llx", request.address))")
} catch {
return errorResult(error.localizedDescription, code: .failed)
}
}
}
private static func registerCallPatternFunction(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "call_pattern_function",
description: """
Decode like decode_memory, then call one of the pattern's functions with the node at \
'path', the way ImHex's button visualizer does, and return what it printed. Functions run \
in the pattern evaluator and cannot touch the process.
""",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"address":{"type":"string","description":"Hex address"},\
\(patternSourceSchema),\
"path":{"type":"string","description":"Node to pass, as field names and [index] steps from the root"},\
"function":{"type":"string"}},\
"required":["session_id","address","function"],"additionalProperties":false}
""",
isObserve: true,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine else { return errorResult("engine unavailable", code: .unavailable) }
guard let function = invocation.args["function"] as? String else {
return errorResult("function is required", code: .invalidInput)
}
do {
let request = try await PatternRequest(invocation.args, engine: engine)
let decoding = try await request.decode(engine: engine)
let node = try decoding.value.descendant(at: request.path)
guard let target = engine.node(forSessionID: request.sessionID)?.processInfo else {
throw PatternMemoryError.detached
}
let output = try await engine.patternDecoder.callFunction(
function, on: node.nodeID, ofText: request.text, typeName: request.typeName, data: decoding.data,
address: request.address, platform: target.platform, arch: target.arch)
return makeResult(jsonObject: ["output": output], summary: "Called \(function)")
} catch {
return errorResult(error.localizedDescription, code: .failed)
}
}
}
private static func registerPlacePattern(in catalog: ToolCatalog, engine: Engine) {
let spec = ActionSpec(
name: "place_pattern",
description: """
Decode a pattern type in a memory insight's view, so the user sees the fields outlined \
over its hex dump. 'offset' is relative to the insight's address. Pin the memory with \
pin_as_insight first. Requires user approval.
""",
inputSchemaJSON: """
{"type":"object","properties":{"session_id":{"type":"string"},"insight_id":{"type":"string"},\
"pattern_id":{"type":"string"},"type":{"type":"string"},"offset":{"type":"integer","minimum":0,"default":0}},\
"required":["session_id","insight_id","pattern_id","type"],"additionalProperties":false}
""",
isObserve: false,
requiresSession: true
)
catalog.register(spec: spec) { [weak engine] invocation in
guard let engine, let sessionID = parseSessionID(invocation.args) else {
return errorResult("missing or invalid session_id", code: .invalidInput)
}
guard let idString = invocation.args["insight_id"] as? String, let insightID = UUID(uuidString: idString),
let insight = engine.insightsBySession[sessionID]?.first(where: { $0.id == insightID })
else {
return errorResult("no such insight on this session; see list_address_insights", code: .notFound)
}
guard insight.kind == .memory else {
return errorResult("patterns can only be placed on memory insights", code: .invalidInput)
}
guard let patternID = invocation.args["pattern_id"] as? String, engine.patterns.source(withID: patternID) != nil,
let typeName = invocation.args["type"] as? String
else {
return errorResult("pattern_id must name a library file and type is required", code: .invalidInput)
}
let placement = PatternPlacement(sourceID: patternID, typeName: typeName, offset: (invocation.args["offset"] as? Int) ?? 0)
engine.setPlacements(insight.placements + [placement], forInsight: insight)
return makeResult(
jsonObject: ["insight_id": insightID.uuidString, "placement_id": placement.id.uuidString],
summary: "Placed \(typeName) on \(engine.displayTitle(for: insight))")
}
}
}
private struct PatternRequest {
let sessionID: UUID
let address: UInt64
let text: String
let typeName: String
let path: PatternPath
private static let initialByteCount = 0x200
@MainActor
init(_ args: [String: Any], engine: Engine) async throws {
guard let sessionID = MissionTools.parseSessionID(args) else { throw PatternRequestError("missing or invalid session_id") }
guard let addressText = args["address"] as? String, let address = MissionTools.parseHexAddress(addressText) else {
throw PatternRequestError("missing or invalid address")
}
if let id = args["pattern_id"] as? String {
guard let source = engine.patterns.source(withID: id) else {
throw PatternRequestError("no pattern file \(id); see list_patterns")
}
text = source.text
} else if let source = args["source"] as? String {
text = source
} else {
throw PatternRequestError("pass pattern_id or source")
}
if let typeName = args["type"] as? String {
self.typeName = typeName
} else {
let summary = try await engine.patternDecoder.summary(ofText: text)
guard let rootType = summary.rootType else {
let names = summary.decodableTypes.map(\.name).joined(separator: ", ")
throw PatternRequestError("the source places nothing at its root; pass type, one of: \(names)")
}
typeName = rootType
}
self.sessionID = sessionID
self.address = address
path = try PatternPath((args["path"] as? String) ?? "")
}
@MainActor
func decode(engine: Engine) async throws -> PatternMemoryDecoding {
try await engine.decodePattern(
text: text, typeName: typeName, sessionID: sessionID, address: address, byteCount: Self.initialByteCount)
}
}
private struct PatternRequestError: LocalizedError {
let message: String
init(_ message: String) {
self.message = message
}
var errorDescription: String? { message }
}