com.sky.ondeviceagent / Runtime /AgentCore /Harness /AgentFrameworkHarness.cs
Sky-Kim's picture
Initial commit
2e7837a
Raw
History Blame Contribute Delete
17.4 kB
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
namespace OnDeviceAgent.AgentCore
{
public enum AgentEventPriority
{
Normal,
High
}
public sealed class AgentEvent
{
public string Source;
public string Prompt;
public AgentEventPriority Priority;
public DateTime TimestampUtc;
}
public sealed class AgentEventBus
{
readonly object m_Lock = new object();
readonly Queue<AgentEvent> m_NormalEvents = new Queue<AgentEvent>();
readonly Queue<AgentEvent> m_HighPriorityEvents = new Queue<AgentEvent>();
public int Count
{
get
{
lock (m_Lock)
return m_NormalEvents.Count + m_HighPriorityEvents.Count;
}
}
public void Publish(string source, string prompt, AgentEventPriority priority)
{
var agentEvent = new AgentEvent
{
Source = string.IsNullOrWhiteSpace(source) ? "Event" : source.Trim(),
Prompt = prompt ?? string.Empty,
Priority = priority,
TimestampUtc = DateTime.UtcNow
};
lock (m_Lock)
{
if (priority == AgentEventPriority.High)
m_HighPriorityEvents.Enqueue(agentEvent);
else
m_NormalEvents.Enqueue(agentEvent);
}
}
public bool TryDequeue(out AgentEvent agentEvent)
{
lock (m_Lock)
{
if (m_HighPriorityEvents.Count > 0)
{
agentEvent = m_HighPriorityEvents.Dequeue();
return true;
}
if (m_NormalEvents.Count > 0)
{
agentEvent = m_NormalEvents.Dequeue();
return true;
}
agentEvent = null;
return false;
}
}
public void Clear()
{
lock (m_Lock)
{
m_NormalEvents.Clear();
m_HighPriorityEvents.Clear();
}
}
}
public sealed class AgentInterruptRequest
{
public bool ShouldCancel;
public string FollowUpPrompt;
public string Reason;
}
public sealed class AgentInterruptController
{
static readonly string[] s_CancelPhrases =
{
"멈춰",
"중지",
"취소",
"그만",
"stop",
"cancel",
"interrupt",
"quit",
"halt"
};
static readonly string[] s_RedirectPhrases =
{
"아니",
"대신",
"다시",
"그거 말고",
"instead",
"actually"
};
public bool TryCreateInterrupt(string text, out AgentInterruptRequest request)
{
request = null;
var normalized = Normalize(text);
if (string.IsNullOrWhiteSpace(normalized))
return false;
if (ContainsAny(normalized, s_CancelPhrases))
{
request = new AgentInterruptRequest
{
ShouldCancel = true,
Reason = "cancel phrase",
FollowUpPrompt = ExtractFollowUpPrompt(text)
};
return true;
}
if (ContainsAny(normalized, s_RedirectPhrases))
{
request = new AgentInterruptRequest
{
ShouldCancel = true,
Reason = "redirect phrase",
FollowUpPrompt = text.Trim()
};
return true;
}
return false;
}
static string ExtractFollowUpPrompt(string text)
{
var trimmed = string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim();
var match = Regex.Match(
trimmed,
"(?:멈춰|중지|취소|그만|stop|cancel|interrupt|quit|halt)[,\\s]*(?<follow>.+)$",
RegexOptions.IgnoreCase);
return match.Success ? match.Groups["follow"].Value.Trim() : string.Empty;
}
static string Normalize(string text)
{
return string.IsNullOrWhiteSpace(text) ? string.Empty : Regex.Replace(text.Trim().ToLowerInvariant(), "\\s+", " ");
}
static bool ContainsAny(string text, string[] phrases)
{
for (var i = 0; i < phrases.Length; i++)
{
if (MatchesPhrase(text, phrases[i]))
return true;
}
return false;
}
static bool MatchesPhrase(string text, string phrase)
{
if (string.IsNullOrEmpty(phrase))
return false;
var lowered = phrase.ToLowerInvariant();
if (IsAsciiPhrase(lowered))
{
var pattern = "\\b" + Regex.Escape(lowered) + "\\b";
return Regex.IsMatch(text, pattern, RegexOptions.IgnoreCase);
}
return text.Contains(lowered);
}
static bool IsAsciiPhrase(string phrase)
{
for (var i = 0; i < phrase.Length; i++)
{
if (phrase[i] > 127)
return false;
}
return true;
}
}
public sealed class AgentTraceStore
{
readonly List<string> m_Events = new List<string>();
int m_MaxEvents = 80;
public void Configure(int maxEvents)
{
m_MaxEvents = Mathf.Max(10, maxEvents);
TrimEvents();
}
public void RecordEvent(string kind, string detail)
{
var line = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture) +
" [" + (string.IsNullOrWhiteSpace(kind) ? "event" : kind.Trim()) + "] " +
AgentHarnessText.OneLine(detail, 320);
m_Events.Add(line);
TrimEvents();
}
public void RecordToolCall(string toolName, string detail)
{
RecordEvent("tool-call", toolName + " " + AgentHarnessText.OneLine(detail, 220));
}
public void RecordToolResult(string toolName, string result)
{
RecordEvent("tool-result", toolName + " -> " + AgentHarnessText.OneLine(result, 260));
}
public string BuildSummary()
{
if (m_Events.Count == 0)
return string.Empty;
var summary = new StringBuilder();
summary.AppendLine("Trace summary:");
for (var i = 0; i < m_Events.Count; i++)
summary.Append("- ").AppendLine(m_Events[i]);
return summary.ToString().Trim();
}
public void Clear()
{
m_Events.Clear();
}
void TrimEvents()
{
while (m_Events.Count > m_MaxEvents)
m_Events.RemoveAt(0);
}
}
public sealed class AgentToolGuardrailController
{
readonly object m_Lock = new object();
readonly Dictionary<string, int> m_CallCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
readonly Dictionary<string, int> m_ResultCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int m_RepeatedCallLimit = 3;
int m_RepeatedResultWarnAfter = 2;
public void Configure(int repeatedCallLimit, int repeatedResultWarnAfter)
{
lock (m_Lock)
{
m_RepeatedCallLimit = Mathf.Max(1, repeatedCallLimit);
m_RepeatedResultWarnAfter = Mathf.Max(1, repeatedResultWarnAfter);
}
}
public void ResetForRun()
{
lock (m_Lock)
{
m_CallCounts.Clear();
m_ResultCounts.Clear();
}
}
public bool TryBeginToolCall(string toolName, string arguments, out string blockedMessage)
{
var key = BuildKey(toolName, arguments);
int count;
int limit;
lock (m_Lock)
{
m_CallCounts.TryGetValue(key, out count);
count++;
m_CallCounts[key] = count;
limit = m_RepeatedCallLimit;
}
if (count <= limit)
{
blockedMessage = string.Empty;
return true;
}
blockedMessage = "Tool guardrail blocked repeated call to " + toolName +
". The same arguments were attempted " + count.ToString(CultureInfo.InvariantCulture) + " times in this run.";
return false;
}
public string InspectToolResult(string toolName, string result, out bool shouldWarn)
{
shouldWarn = false;
var normalized = AgentHarnessText.OneLine(result, 500);
var key = toolName + ":" + HashText(normalized);
int count;
int warnAfter;
lock (m_Lock)
{
m_ResultCounts.TryGetValue(key, out count);
count++;
m_ResultCounts[key] = count;
warnAfter = m_RepeatedResultWarnAfter;
}
if (count <= warnAfter)
return result;
shouldWarn = true;
return result + "\n\nGuardrail note: this tool returned the same result repeatedly in the current run. Avoid retrying unless the user asks.";
}
static string BuildKey(string toolName, string arguments)
{
return (toolName ?? string.Empty).Trim() + ":" + HashText(arguments ?? string.Empty);
}
static string HashText(string text)
{
// FNV-1a 64-bit hash; not a security context.
const ulong offset = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
var hash = offset;
var s = text ?? string.Empty;
for (var i = 0; i < s.Length; i++)
{
hash ^= s[i];
hash *= prime;
}
return hash.ToString("x16");
}
}
public sealed class AgentMemoryCompactionPolicy
{
public int RecentTurnCount = 6;
public int CompressionThreshold = 12;
public int MaxContextChars = 8000;
public string BuildContext(string summary, List<ConversationTurn> turns)
{
var maxContextChars = Mathf.Max(1000, MaxContextChars);
var memory = new StringBuilder();
if (!string.IsNullOrWhiteSpace(summary))
{
memory.AppendLine("Summary:");
memory.AppendLine(AgentHarnessText.TrimToMaxChars(summary.Trim(), maxContextChars / 2));
}
if (turns != null && turns.Count > 0)
{
if (memory.Length > 0) memory.AppendLine();
memory.AppendLine("Recent turns:");
var recentCount = Mathf.Max(1, RecentTurnCount);
var start = Math.Max(0, turns.Count - recentCount);
for (var i = start; i < turns.Count; i++)
AppendMemoryTurn(memory, turns[i]);
}
return AgentHarnessText.TrimToMaxChars(memory.ToString(), maxContextChars);
}
public bool ShouldCompact(List<ConversationTurn> turns)
{
if (turns == null)
return false;
var olderCount = turns.Count - Mathf.Max(1, RecentTurnCount);
return olderCount >= Mathf.Max(1, CompressionThreshold);
}
public string BuildSummary(List<ConversationTurn> turns)
{
var maxSummaryChars = Mathf.Max(2000, Math.Min(12000, MaxContextChars));
var summary = new StringBuilder();
summary.AppendLine("# Conversation Memory Summary");
summary.AppendLine("UpdatedUtc: " + DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
summary.AppendLine("CompressedTurns: " + (turns == null ? 0 : turns.Count).ToString(CultureInfo.InvariantCulture));
var factLines = ExtractMemoryFactLines(turns, 24);
if (factLines.Count > 0)
{
summary.AppendLine();
summary.AppendLine("## Stable User Notes");
for (var i = 0; i < factLines.Count; i++)
summary.AppendLine("- " + factLines[i]);
}
summary.AppendLine();
summary.AppendLine("## Compressed Timeline");
if (turns != null)
{
var start = Math.Max(0, turns.Count - 40);
for (var i = start; i < turns.Count; i++)
AppendTimelineTurn(summary, turns[i]);
}
return AgentHarnessText.TrimToMaxChars(summary.ToString(), maxSummaryChars);
}
static void AppendMemoryTurn(StringBuilder builder, ConversationTurn turn)
{
// No timestamp on purpose: leaking ~now lets the model answer "what time is it?" from context instead of GetCurrentTime.
builder.AppendLine(" User: " + AgentHarnessText.OneLine(turn.User, 500));
if (!string.IsNullOrWhiteSpace(turn.Assistant))
builder.AppendLine(" Assistant: " + AgentHarnessText.OneLine(turn.Assistant, 700));
if (turn.Tools != null && turn.Tools.Count > 0)
builder.AppendLine(" Tools: " + string.Join(", ", turn.Tools.ToArray()));
}
static void AppendTimelineTurn(StringBuilder builder, ConversationTurn turn)
{
builder.Append("- ").Append(AgentHarnessText.FormatTimestamp(turn.TimestampUtc)).Append(" ");
builder.Append("User: ").Append(AgentHarnessText.OneLine(turn.User, 260));
if (!string.IsNullOrWhiteSpace(turn.Assistant))
builder.Append(" | Assistant: ").Append(AgentHarnessText.OneLine(turn.Assistant, 360));
if (turn.Tools != null && turn.Tools.Count > 0)
builder.Append(" | Tools: ").Append(string.Join(", ", turn.Tools.ToArray()));
builder.AppendLine();
}
static List<string> ExtractMemoryFactLines(List<ConversationTurn> turns, int maxCount)
{
var facts = new List<string>();
if (turns == null)
return facts;
for (var i = turns.Count - 1; i >= 0 && facts.Count < maxCount; i--)
{
var user = turns[i].User;
var normalizedUser = AgentHarnessText.Normalize(user);
if (!AgentHarnessText.ContainsAny(normalizedUser, "remember", "preference", "prefer", "like", "always", "never", "기억", "선호", "좋아", "싫어", "원해"))
continue;
facts.Add(AgentHarnessText.FormatTimestamp(turns[i].TimestampUtc) + " " + AgentHarnessText.OneLine(user, 300));
}
facts.Reverse();
return facts;
}
}
public static class AgentHarnessText
{
public static string Normalize(string text)
{
return string.IsNullOrWhiteSpace(text) ? string.Empty : text.Trim().ToLowerInvariant();
}
public static bool ContainsAny(string text, params string[] keywords)
{
if (string.IsNullOrWhiteSpace(text))
return false;
for (var i = 0; i < keywords.Length; i++)
{
if (!string.IsNullOrWhiteSpace(keywords[i]) && text.Contains(keywords[i].ToLowerInvariant()))
return true;
}
return false;
}
public static string OneLine(string text, int maxChars)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
var normalized = Regex.Replace(text.Trim(), "\\s+", " ");
return TrimToMaxChars(normalized, maxChars);
}
public static string TrimToMaxChars(string text, int maxChars)
{
if (string.IsNullOrEmpty(text) || maxChars <= 0 || text.Length <= maxChars)
return text ?? string.Empty;
if (maxChars <= 20)
return text.Substring(0, maxChars);
var headLength = Math.Max(8, maxChars / 3);
var tailLength = maxChars - headLength - 16;
if (tailLength <= 0)
return text.Substring(0, maxChars);
return text.Substring(0, headLength) + "\n...[truncated]...\n" + text.Substring(text.Length - tailLength);
}
public static string FormatTimestamp(string timestampUtc)
{
DateTime parsed;
if (DateTime.TryParse(timestampUtc, null, DateTimeStyles.RoundtripKind, out parsed))
return parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
return string.IsNullOrWhiteSpace(timestampUtc) ? "unknown time" : timestampUtc;
}
}
}